Commit Graph

95 Commits

Author SHA1 Message Date
Александр Зимин 8621687a60 feat(dict): 7-day retention auto-purge + inline restore UI 2026-06-09 13:40:23 +00:00
Александр Зимин 0ccdff95d2 feat(dict): soft-delete с recovery (Phase 1) 2026-06-08 14:27:34 +00:00
Александр Зимин 5c6801f993 feat(dict): delete dictionary endpoint + UI button с cascade safety 2026-06-05 09:47:05 +00:00
Александр Зимин 30506b0cc4 fix(approve): retry на Postgres SERIALIZABLE 40001 (массовый approve drafts) 2026-06-02 11:56:03 +00:00
Александр Зимин ba49854a61 feat(workflow): admin self-approve override (D3 relaxation для small teams) 2026-06-01 18:46:15 +00:00
Александр Зимин c1aaf6a696 fix(auth): зачистить остатки auth.nstart.space → altum.nstart.cloud 2026-05-26 13:17:58 +00:00
Александр Зимин a4ba8735d0 feat(auth): Phase 2 — audited-users UserPicker + Tier-3 Keycloak Admin lookup 2026-05-26 12:01:09 +00:00
Zimin A.N. 3f05741ed1 feat: /audit поле «Пользователь» — typeahead picker из user-cache
Backend: GET /api/v1/admin/users (INTERNAL+ scope) — list cached юзеров
из user_display_cache. UserDisplayService.listAll(limit) сортирует по
preferred_username, hard-cap 1000. Контроллер: UserDisplayController.list().

Frontend:
- queries.ts → useAllUsers() / allUsersQuery (5min stale, 403/404 → []).
- components/audit/UserPicker.tsx (новый, ~140 строк) — cmdk-powered
  combobox: trigger показывает username (или UUID prefix fallback),
  popover c поиском по preferredUsername/email/UUID. X сбрасывает selection.
- routes/audit.tsx — TextInput → UserPicker.

UX: раньше юзер вручную копипастил UUID из audit-row'а; теперь поиск
по имени/email типа `solov` → находит `solovyev.da`.
2026-05-25 16:48:41 +03:00
Zimin A.N. b6a41f47a8 fix(rest-api): bean-валидация возвращает 400 вместо 500
GlobalExceptionHandler не имел @ExceptionHandler для
MethodArgumentNotValidException — провал bean-валидации @Valid
@RequestBody (@NotBlank businessKey, @NotNull data и т.п.) падал в
generic Exception catch → HTTP 500 internal_error с пустым traceId.

admin-ui на создании/редактировании записи получал бесполезный
«Internal server error» без полей вместо 400 с пофайловой разбивкой.

Добавлен handler → 400 validation_failed с FieldError-списком.
Покрыто GlobalExceptionHandlerTest.
2026-05-21 13:28:25 +03:00
Zimin A.N. d908febc91 fix(snapshots): handle Instant return from pgJDBC 42.7+ (was 500)
ClassCastException on every GET /api/v1/dictionaries/{name}/snapshots:
  java.lang.ClassCastException: class java.time.Instant cannot be cast
  to class java.sql.Timestamp

Root cause: pgJDBC 42.7+ возвращает timestamptz из native query как
java.time.Instant, а старый код жёстко кастил в java.sql.Timestamp.
42.6 возвращал Timestamp — отсюда и тесты с Timestamp проходили,
а runtime ломался.

Fix: helper `toBucketOffsetDateTime(Object raw, OffsetDateTime now)`
ловит все 3 возможных типа от драйвера:
  - java.sql.Timestamp (pgJDBC 42.6 и ранее)
  - java.time.Instant (pgJDBC 42.7+ — current behavior)
  - java.time.OffsetDateTime (если jdbc.timestamp.return-type=offset-date-time)

При любом другом типе бросаем IllegalStateException — fail-loud вместо
silent CCE на каждом запросе.

Tests:
  + snapshots_acceptsInstantFromPg42_7Driver — regression на конкретный
    bug (Instant from current driver)
  + snapshots_acceptsOffsetDateTime — defensive coverage
  + snapshots_rejectsUnexpectedTimestampType — fail-loud branch

Existing tests (с Timestamp) сохранены для backward-compat.
2026-05-19 23:01:37 +03:00
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
Zimin A.N. c8dd5aad4e feat(schema): Phase 1 schema templates + Nexus DevOps runbook
## AI Schema Assist Phase 1 (steps 1-2)

Approach A core (templates only, no LLM) — admin начинает создание нового
справочника с curated skeleton вместо пустого Monaco. Approach C (per-field
LLM suggest) — Phase 2 на этом фундаменте.

### Backend
- `templates/*.template.json` в ordinis-cuod-bundle resources — 6 шаблонов
  (blank/spacecraft/ground-station/antenna/frequency-band/operator)
- `SchemaTemplateRegistry` (rest-api) — classpath scan at startup, strip
  x-template-* metadata, expose canonical JSON Schema
- `SchemaTemplatesController` — GET /api/v1/dictionaries/templates (list)
  + /{id} (detail с schemaJson)
- Cross-bundle support: scanner matches classpath*:templates/*.template.json
  — другие bundles могут shippит свои templates через те же conventions

### Frontend
- types: SchemaTemplateSummary + SchemaTemplateDetail
- queries: schemaTemplatesQuery + lazy detail query
- TemplatePicker.tsx — grid of cards (icon + name + description + tags),
  click → fetch detail → fire onPick(detail)
- Integrated в DictionaryEditorDialog (create mode, properties.length===0
  visible — после первой property picker исчезает чтобы не перезатереть)

## Nexus DevOps handoff

`docs-internal/ops/nexus-alpine-mirror.md` — runbook про APKINDEX vs file
inventory drift на v3.23 mirror. Includes:
- Verification commands
- 3 fix options (invalidate cache / scheduled refresh / self-host)
- Prometheus alert proposal
- Current workaround (node:22-alpine3.20 pin) reference

Active issue paired с CI saga !227/!229/!230 — нужен DevOps action.
2026-05-16 13:20:43 +03:00
Zimin A.N. 35147df683 fix(search): scope filter uppercase — fixes Smart Search 0 results bug
QA report BUG-005: "/search" возвращал 0 результатов на любые запросы
(«антенна», «GS_MOSCOW», «super» — всё пусто).

Root cause: SearchController convertedil scope enum в lowercase перед
WHERE filter:
  data_scope = ANY(CAST(? AS TEXT[]))  -- value: 'public'

Но DB stores UPPERCASE per migrations 0008/0009 CHECK constraint:
  CHECK (data_scope IN ('PUBLIC', 'INTERNAL', 'RESTRICTED'))

Filter 'public' никогда не матчил 'PUBLIC' → empty result для всех queries.
Багу 5+ месяцев — comment в коде утверждал что DB lowercase, что было
неправдой даже в момент написания.

Fix: убрать .toLowerCase() — pass enum names как есть. Verified:
  curl /api/v1/antenna/records?as_scope=PUBLIC возвращает GS_MOSCOW_A1
  но curl /api/v1/search?q=GS_MOSCOW возвращал 0. После fix должен матчить.
2026-05-15 22:51:01 +03:00
Zimin A.N. ff7e72e0fd fix(qa-report): version tag, /drafts redirect, audit filter complete, i18n cleanup
QA report v2.31.x batch:

- **VersionController.tag**: было 'none' (maven build на main без git tag) → UI
  показывал «dev · 6077d4df». Helm передаёт image.tag через env
  ORDINIS_BUILD_IMAGE_TAG, controller парсит «v2-31-2-e3c82775» → «v2.31.2».
  Helm chart change в ordinis-infra (separate MR).

- **/drafts redirect** (BUG-003): пустой 404 «Not Found» plain text → теперь
  static redirect на /my-drafts.

- **Audit action filter** (BUG-004): дропдаун содержал только 3 значения
  (CREATE/UPDATE/CLOSE), но в таблице 11 типов (DRAFT_*, DEFINITION_*).
  Frontend AuditAction enum + ACTIONS list синхронизированы с backend
  AuditLogger/SchemaDraftService/DraftService.

- **i18n cleanup** (BUG-006/007/UX-005):
  - myDrafts.description: «Все ваши submissions с pending/approved...» →
    «Все ваши черновики с историей: ожидает рассмотрения, одобрен...»
  - reviews.description: «Pending drafts от makers, ждут approve/reject» →
    «Черновики от авторов, ждут одобрения или отклонения. Общая очередь...»
  - reviews.queueTotal: «N pending drafts» → «N черновиков в очереди»
  - myDrafts.col.reviewer: «Reviewer» → «Рецензент» (RU only)
  - notifications chip labels: 'submit/approved/rejected' → 'отправлен/
    одобрен/отклонён'
  - audit.action.* expanded к 13 entries для каждого backend action
    type, с понятными лейблами «Черновик одобрен» вместо «DRAFT_APPROVE»

- **WhatsNewModal markdown** (UX-006): тексты вроде «**График доставки
  webhook'ов**» рендерились с буквальными звёздочками. Mini inline renderer
  для **bold**, *italic*, `code` — без full markdown library.
2026-05-15 22:02:29 +03:00
Александр Зимин f04b1be1f6 feat(notifications): Phase B-2 — per-event-type granular preferences 2026-05-15 16:53:32 +00:00
Александр Зимин fd35e71fe8 fix(rest-api): preserve status code from ResponseStatusException 2026-05-15 15:20:21 +00:00
Александр Зимин 9452adaf55 feat(notifications): Phase B backend — per-user channel preferences 2026-05-15 14:56:25 +00:00
Александр Зимин e43e3f617f fix(schema-drafts): /drafts/active returns 200+null instead of 404 (no console spam) 2026-05-14 22:41:45 +00:00
Александр Зимин f028ec5796 fix(notifications): panic-level defensive GET /me/notifications 2026-05-14 21:50:51 +00:00
Александр Зимин 0cf76d23c1 fix(notifications): defensive /me/notifications — wrap DB ops in try/catch 2026-05-14 21:00:42 +00:00
Александр Зимин 9050e2427e feat(notifications): TODO 7 — draft decision toast + email + bell badge 2026-05-14 14:40:35 +00:00
Александр Зимин d338c52c63 fix(approval): allow non-breaking schema changes inline 2026-05-14 12:35:39 +00:00
Александр Зимин 9ec723dd17 Merge branch 'feat/record-draft-audit-outbox' into 'main'
feat(drafts): audit + outbox для record-draft lifecycle (audit findings)

See merge request 2-6/2-6-4/terravault/ordinis!184
2026-05-14 12:32:06 +00:00
Александр Зимин 0003fb6801 feat(drafts): audit + outbox для record-draft lifecycle (audit findings) 2026-05-14 12:32:06 +00:00
Александр Зимин 4e0902706e fix(approval): close 2 more bypass paths from security audit 2026-05-14 12:32:03 +00:00
Andrei Zimin 018ebff207 fix(schema): enforce approvalRequired on PUT /{name} + PATCH /{name}/schema
Reported 2026-05-14: editing schema on an approvalRequired dictionary
applied inline without going through the schema-draft maker-checker
workflow. Two endpoints both lacked the gate that DictionaryRecordService
already had for record CRUD:

1. PUT /api/v1/admin/dictionaries/{name}
   (frontend SchemaDrivenForm "edit schema" save button hits this).
   DictionaryDefinitionService.updateSchema overwrote schemaJson directly.
   Bonus backdoor: same request could simultaneously flip
   approvalRequired:true→false and rewrite the schema in one transaction
   — review-bypass via toggle.

2. PATCH /api/v1/admin/dictionaries/{name}/schema (inline non-breaking
   patch — description / title / minimum / maximum / enum-add). Frontend
   doesn't currently call this, but backend exposed it without a gate.

Both now reject with `422 approval_required` carrying the schema-drafts
URL in the message. Frontend's existing serverErrorMessage in
DictionaryEditorDialog already surfaces backend `message` to the user,
so no UI changes needed for correctness. UX polish (hide "Save" /
show "Create draft" CTA when approvalRequired=true) is a follow-up.

The PUT gate is narrow on purpose: schemaChanged OR approvalRequired
toggle-off. Pure metadata edits (display name, description, locales,
projection flag) on an approvalRequired dict still go through inline
— those don't affect record validation, no review needed.
2026-05-14 15:05:09 +03:00
Andrei Zimin 83caf9c3c8 feat(user-display): KeycloakAdminUserResolver — Phase 2 on-demand lookup
Implements the Phase 2 hook from the previous commit. With this in
place, UserDisplayService.find chains:

  hot cache → DB (user_display_cache) → Keycloak Admin API

so any realm user resolves on first display, even if they never made
an authenticated request to ordinis. The resolved entry is persisted
via UserDisplayService.upsert with source=KEYCLOAK_ON_DEMAND, so the
next render of the same sub hits the DB tier (~ms) instead of going
back to Keycloak.

Implementation notes:

- Spring RestClient (modern blocking HTTP, replaces RestTemplate).
- client_credentials grant against
  {issuer}/realms/{realm}/protocol/openid-connect/token. Token cached
  in-memory minus a 30s safety margin from `expires_in`.
- 401 on user lookup wipes the cached token and surfaces a
  RuntimeException, so the next resolve refetches.
- 404 on user lookup → Optional.empty (definitive miss, sub not in
  realm). UserDisplayService logs and the caller falls back to short
  UUID. Negative cache TTL not implemented — added complexity not
  justified for the current realm size (~50 users).
- Bean activated only when ordinis.keycloak.admin.enabled=true AND
  the env vars are set. Default disabled — UserDisplayService treats
  KeycloakUserResolver as @Autowired(required = false), so a
  deployment without Keycloak admin creds keeps the previous behavior
  (DB cache only) instead of crashing on startup.

Application config:
- ORDINIS_KEYCLOAK_ADMIN_ENABLED      (false by default)
- ORDINIS_KEYCLOAK_ADMIN_URL          e.g. https://auth.nstart.space
- ORDINIS_KEYCLOAK_ADMIN_REALM        defaults to nstart
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_ID    via vault (see docs-internal/keycloak-admin-setup.md)
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET via vault

Setup runbook (docs-internal/keycloak-admin-setup.md) covers:
- creating the Keycloak service-account client + assigning view-users
- writing the secret into both vault instances (different per env)
- wiring vault.hashicorp.com agent-inject annotations into the chart
- verification steps + common failure modes

Phase 3 (scheduled bulk sync) is documented at the end of the runbook
but deferred — depends on observed cache miss rate after this lands.
2026-05-14 14:47:49 +03:00
Andrei Zimin ec0c74afdf feat(user-display): persistent DB cache + Keycloak resolver hook (Phase 1+2 wiring)
Closes "ток мой UUID отображается" pain reported on staging today: any
reviewer / maker / publisher / actor whose JWT sub never landed in the
JVM cache shows up as a short UUID in audit / reviews / changelog /
events / history / record drawer / webhook detail / record version
history. Restart the pod and even captured users disappear.

Three-tier resolve, replacing the in-memory-only cache that was the
"Альтернатива (на будущее)" TODO in the original UserDisplayService:

  1. JVM ConcurrentHashMap hot cache (~µs, capped at 10k).
  2. user_display_cache Postgres table (~ms, persistent across restart).
     New migration 0023 — sub PK + preferred_username + name + email +
     source enum + synced_at + updated_at, plus an index on synced_at
     for the scheduled bulk sync that lands later.
  3. Keycloak Admin API on-demand (Phase 2) — dispatched to optional
     KeycloakUserResolver bean. When the bean isn't wired (no Keycloak
     admin creds yet), the third tier is a no-op and UserCell falls
     back to short UUID exactly like before.

Source enum on every row — JWT_CAPTURE / KEYCLOAK_SYNC /
KEYCLOAK_ON_DEMAND — so the eventual scheduled sync can prefer JWT-
captured rows (richest claims as the user actually saw them) and the
on-demand fallback can be distinguished from bulk sync in audits.

JwtUserCaptureFilter and UserDisplayController are unchanged — they
already used UserDisplayService.put / find with the same signatures.
The behavior change is invisible at the API layer: same /admin/users/
{sub}/display endpoint, same 404 user_not_cached on miss; persistence
just survives pod restart now.

mergeFrom on UserDisplayCacheEntry deliberately keeps the existing
non-blank field when an update brings null — Keycloak sync may have a
richer email than JWT capture; don't blank it out.

Phase 2 will land a KeycloakAdminUserResolver impl + vault secret
plumbing on this same branch. Phase 3 (scheduled bulk sync via
@Scheduled) is a separate sprint — it needs the Keycloak admin client
already in place.
2026-05-14 14:45:14 +03:00
Александр Зимин 95c2b46dd3 Merge branch 'feat/user-display-endpoint' into 'main'
feat(users): /admin/users/{sub}/display endpoint + UserCell uses it

See merge request 2-6/2-6-4/terravault/ordinis!177
2026-05-13 16:42:46 +00:00
Александр Зимин be90077520 feat(users): /admin/users/{sub}/display endpoint + UserCell uses it 2026-05-13 16:42:46 +00:00
Александр Зимин 0a9deb51bd Merge branch 'feat/admin-role-gate-audit-outbox-webhooks' into 'main'
feat(admin): scope-gated audit и outbox endpoints

See merge request 2-6/2-6-4/terravault/ordinis!176
2026-05-13 16:42:12 +00:00
Александр Зимин da17930def feat(admin): scope-gated audit и outbox endpoints 2026-05-13 16:42:11 +00:00
Александр Зимин 5187845047 feat(approval): enforce per-dictionary approvalMinRole (Phase 2) 2026-05-13 16:42:09 +00:00
Александр Зимин f096b5746d feat(my-drafts): schema drafts тоже в "Мои черновики" 2026-05-13 12:45:45 +00:00
Zimin A.N. 4fe7f1e800 fix(auth): @Autowired на WorkflowRoles main ctor
Класс имеет два constructor'а (main + test). Без явной @Autowired
аннотации Spring может выбрать no-arg ctor → props=null →
rolesClaimPath() fallback на realm_access.roles несмотря на
env var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles.

Симптом: 403 forbidden_role с message 'Требуется realm role:
ordinis:schema:reviewer' для user с ordinis:admin в
resource_access.ordinis.roles (super-role override не работал
потому что currentRoles() возвращал пустой set из неправильного
claim path).

Fix: @Autowired на main ctor — Spring выбирает его, props
инжектится, rolesClaim читается из env var правильно.
2026-05-13 13:49:07 +03:00
Zimin A.N. 36c06ae1da feat(auth): WorkflowRoles configurable claim path + ordinis:admin super-role
User report: token has roles в resource_access.ordinis.roles (client-scoped),
backend WorkflowRoles читал hardcoded realm_access.roles → forbidden_role
для всех reviewer actions.

Backend:
- WorkflowRoles: depends OrdinisAuthProperties → reads via rolesClaim path
  (же что ScopeContext). Default realm_access.roles, на staging/prod env
  var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles (уже wired
  в helm helper).
- Nested claim path resolver — mirror ScopeContext.readClaimByPath
- New super-role ADMIN ('ordinis:admin') — holder автоматически проходит
  любой workflow check без явного назначения отдельных ролей. Convenience
  для small teams.

Frontend (usePermissions.ts):
- tokenRoles() читает оба claim path (realm_access.roles +
  resource_access.ordinis.roles) и объединяет — UI gate работает
  независимо от backend config
- hasRole() recognizes 'ordinis:admin' как super-role override
- ROLE_ADMIN exported

Docs:
- docs/operator/rbac.md: добавлен ordinis:admin row, JWT claim path note,
  Admin profile updated (super-role vs composite explanation)
2026-05-13 13:17:54 +03:00
Zimin A.N. c5b00ff623 refactor(auth): domain-specific role names (consistent convention)
User feedback: универсальный 'ordinis:approver' отступал от convention
'ordinis:<domain>:<action>'. Меняю на domain-specific:

WorkflowRoles constants:
- APPROVER → SCHEMA_REVIEWER ('ordinis:schema:reviewer')
- PUBLISHER → SCHEMA_PUBLISHER ('ordinis:schema:publisher')
- Новая RECORD_REVIEWER ('ordinis:record:reviewer')

Applied:
- SchemaDraftService.decide() → require SCHEMA_REVIEWER
- SchemaDraftService.publish() → require SCHEMA_PUBLISHER
- DraftService.approve() / .reject() → require RECORD_REVIEWER

Frontend usePermissions.ts:
- ROLE_APPROVER + ROLE_PUBLISHER → ROLE_SCHEMA_REVIEWER +
  ROLE_SCHEMA_PUBLISHER + ROLE_RECORD_REVIEWER
- Новый hook useCanReviewRecord() для record draft actions

Keycloak setup (updated):
- ordinis:schema:reviewer
- ordinis:schema:publisher
- ordinis:record:reviewer
(Composite role 'ordinis:admin' = all три + scope roles — для удобства.)
2026-05-13 12:44:23 +03:00
Zimin A.N. 24aa94d130 feat(auth): Phase 1d — backend role enforcement для workflow
User explicitly requested 'Phase 1d пора'. До этого MR'а backend гейтил
только maker-checker (self_approve_forbidden), frontend hooks возвращали
auth.isAuthenticated.

Реализация:

Backend WorkflowRoles (ordinis-rest-api/auth/WorkflowRoles.java):
- Component reads realm_access.roles из JWT claim
- Constants: ordinis:approver (decide/approve/reject), ordinis:publisher (publish)
- requireRole(role) throws 403 forbidden_role если missing
- Defensive parse: malformed/missing realm_access → empty roles
- 9 unit tests cover authenticated + anonymous + malformed JWT

Applied gates:
- SchemaDraftService.decide() — require ordinis:approver
- SchemaDraftService.publish() — require ordinis:publisher
- DraftService.approve() — require ordinis:approver
- DraftService.reject() — require ordinis:approver

WorkflowRoles is Optional в test ctors → unit tests без auth setup
не ломаются. Все existing tests должны проходить (legacy ctor overloads
сохранены).

Frontend usePermissions.ts:
- Stub return auth.isAuthenticated заменён на real role decode из
  auth.user.profile.realm_access.roles
- ROLE_APPROVER + ROLE_PUBLISHER constants exported
- Defensive parsing — graceful с missing/malformed claims
- useCanCreateSchemaDraft остаётся permissive (любой auth — maker'ом
  может быть anyone, scope ACL заведует видимостью)

Naming convention — colon-separated (consistent с ordinis:client:*
scope ролями), aligned с MR !161.

MIGRATION (BREAKING без правильной Keycloak setup):
1. Keycloak realm 'nstart' → Realm Roles → Create:
   - ordinis:approver
   - ordinis:publisher
2. Users → assign roles:
   - reviewer/approver users → ordinis:approver
   - publisher users → ordinis:approver + ordinis:publisher
3. После refresh JWT (logout/login) роли появляются в realm_access.roles

Без этого все decide/publish операции вернут 403 forbidden_role.
2026-05-13 12:36:53 +03:00
Zimin A.N. f2adc37e2e fix(restapi): NoResource/NoHandler → 404, не 500
GlobalExceptionHandler catch-all Exception → 500 ловил NoResourceFoundException
и NoHandlerFoundException. Симптомы на prod:

- /swagger-ui/index.html при disabled springdoc возвращал 500 (UI всё равно
  не рендерился, но spring логировал 'Unhandled exception' на каждый запрос —
  спамил alerting и затруднял поиск настоящих ошибок).
- GET /api/v1/typo от integrator'ов давал 500 вместо 404, осложняло
  debugging без traceId-correlation.
- Любой stale frontend bundle с переименованным endpoint мог триггерить
  false-positive incident alerts.

Fix: explicit @ExceptionHandler для обоих exception типов → 404 not_found.
Логирование DEBUG (не WARN/ERROR) — это ожидаемый 404, не аномалия.

Test: SmokeE2ETest.unknownPath_returns404WithNotFoundCode покрывает оба пути
(/api/v1/* и /swagger-ui/*) → 404 + body.code='not_found'.
2026-05-13 00:00:36 +03:00
Александр Зимин 5479142093 feat(webhook): time-series histogram stats endpoint + frontend chart 2026-05-12 14:30:22 +00:00
Александр Зимин b3a9104d54 feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A) 2026-05-12 11:33:01 +00:00
Александр Зимин 72268f9add feat(api): GET /changelog/{entryId}/diff — full before/after schemas 2026-05-12 08:51:28 +00:00
Александр Зимин 12975e0d83 feat(api): full timeline в /changelog — workflow events + draft kinds 2026-05-12 00:14:09 +00:00
Александр Зимин 01fc5771f2 feat(api): schema workflow API — backend #3 (drafts/review/decision/publish) 2026-05-11 23:11:56 +00:00
Zimin A.N. f5e7348f92 fix(audit): definition action != record action — fix SmokeE2ETest filter
SmokeE2ETest.contextLoadsAndCreatesRecord фильтрует audit_log по
action='CREATE' и ожидает row с businessKey. Мой backend #1 commit
сделал definitionCreated() писать action='CREATE' — конфликт с
record-level CREATE rows: filter подбирал dict-level row первой
(no businessKey → null assertion fail).

Fix: definitionCreated/definitionUpdated теперь пишут action
'DEFINITION_CREATE' / 'DEFINITION_UPDATE'. event_type остаётся
DEFINITION_CREATED/UPDATED (changelog query filter по event_type
IN список, не задеть).
2026-05-11 23:23:21 +03:00
Zimin A.N. db4f859ef6 feat(api): PATCH /dictionaries/{name}/schema — inline non-breaking field edit (backend #4)
Backend #4 из pending-endpoints brief. Frontend Fields tab inline edit:
кликаешь field row → form показывает только allowed (non-breaking) changes
→ submit → patch component bumps (1.4.0 → 1.4.1) без full DictionaryEditorDialog.

Body:
```json
{
  "expectedHeadVersion": "1.4.0",
  "field": "altitude",
  "changes": { "description": {"ru":"Высота орбиты"}, "minimum": 0 }
}
```

Allowed change keys: description / title / minimum / maximum / enum.
Breaking keys (type/format/x-references/etc) → 422 breaking_change_requires_draft.

Validation:
- minimum tighter than current → 422 validation_failed (могло бы invalidate existing)
- maximum tighter than current → 422 validation_failed
- enum removing existing values → 422 (only additions allowed inline)
- expectedHeadVersion != actual → 409 version_mismatch (optimistic lock)

Implementation:
- PatchSchemaRequest DTO (record).
- SchemaPatchService: gates dict/scope/version, applies whitelisted changes
  on deep-copied JsonNode, bumps patch component via bumpPatchVersion helper
  (semver-safe + fallback для non-semver).
- DictionaryDefinitionController новый @PatchMapping.
- OrdinisException.unprocessableEntity helper (422).

Audit: hooks через existing AuditLogger.definitionUpdated после save.

Tests +10: 4xx scenarios, description update bumps version, enum add ok,
enum remove blocked, minimum expand ok / tighten blocked, version bump
helpers (semver + non-semver fallback).
2026-05-11 23:13:04 +03:00
Zimin A.N. e02e684cca feat(api): GET /dictionaries/{name}/snapshots — record-change buckets (backend #2)
Backend #2 из pending-endpoints brief. Frontend TimeTravelModal использует
этот endpoint для slider marks — каждая точка = bucket где минимум одна
запись validFrom попадает.

GET /api/v1/dictionaries/{name}/snapshots?from=...&to=...&granularity=day

Implementation:
- DictionaryRecordRepository.findSnapshotBuckets — native SQL date_trunc
  GROUP BY bucket, ORDER BY bucket DESC. scope filter через text[] из CSV.
- SnapshotsService — gates dict existence/scope, validates granularity
  (hour/day/week) + window, formats RU label (1 апр / сейчас), reverses
  список чтобы oldest first (UI читает слева-направо).
- DictionaryChangelogController новый endpoint /snapshots.

Errors:
- 404 dictionary_not_found
- 400 invalid_granularity (not in {hour,day,week})
- 400 invalid_window (from >= to)

Frontend impact: TimeTravelModal slider marks переходит с recordTimestamps
derived data на реальный API. ~1h frontend swap.
2026-05-11 23:08:07 +03:00
Zimin A.N. ebc90d76e9 feat(api): GET /dictionaries/{name}/changelog — schema versions timeline (backend #1)
Backend #1 из pending-endpoints brief. Frontend HistoryTabContent +
TimeTravelModal используют этот endpoint для отображения версий схемы
во времени с per-version property-level diff summary.

Implementation:
- AuditLogger.definitionCreated / definitionUpdated — пишет в audit_log
  с event_type DEFINITION_CREATED / DEFINITION_UPDATED. payload_before/
  after = JsonNode schema. record_id / business_key — NULL (dict-level event).
- DictionaryDefinitionService.create / updateSchema hook в audit logger
  (autowired, null-safe для tests).
- AuditLogRepository.findByDictionaryIdAndEventTypes — query by dict_id +
  event_type IN (...), ORDER BY event_time DESC, paginated.
- ChangelogService.findChangelog — собирает entries, computes top-level
  property-key diff (added/removed/changed). Первая entry (newest) =
  isCurrent + schemaVersion из DictionaryDefinition; старые entries
  возвращают version=null (semver-в-аудит backfill — separate migration).
- DictionaryChangelogController — GET /dictionaries/{name}/changelog?limit=50

Errors:
- 404 dictionary_not_found когда dict missing OR scope hidden (consistent
  с другими endpoints).

Tests +9: dict-not-found, scope-hidden, empty-audit, first-is-current,
diff-added/removed/changed/null-before/identical.
2026-05-11 23:04:09 +03:00
Zimin A.N. ff05ef9a9f feat(api): GET /dictionaries/graph/outgoing — batch outgoing FK summary
Frontend admin-UI catalog list (`→` column) парсил schemaJson per-row
для подсчёта outgoing FK refs. Этот endpoint возвращает batch:

  GET /api/v1/dictionaries/graph/outgoing?dicts=satellites,operators

  → { "outgoing": {
      "satellites": { "fkCount": 3, "targets": ["launch_sites", "missions", "operators"] },
      "operators":  { "fkCount": 0, "targets": [] }
    } }

Implementation:
- LineageIndexService.findOutgoingFkSummary — обходит DictionaryDefinitionRepo.findAll()
  + ScopeContext filter, парсит x-references через ReferenceValidator.parseRef,
  dedup target dict names, sort alphabetically.
- LineageController новый endpoint + OutgoingGraphResponse wrapper record
  (extensible — sibling fields в будущем без breaking).

Errors: scope hide (тихо skip — same pattern что и GET /dictionaries).
Malformed x-references логируются WARN + skip (не падают на одной плохой
schema).

Tests +6 cases: empty, basic counts, scope hide, whitelist filter, dedup
repeated target, malformed-not-fatal.
2026-05-11 22:54:43 +03:00