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.
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.
Класс имеет два 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 правильно.
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)
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'.
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 список, не задеть).
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.
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.
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.
CEO plan v1 stretch — закрывает gap "Smart JSONB search across all dictionaries".
Last gap из v1 list shipped.
Migration 0018:
- CREATE EXTENSION pg_trgm (CNPG initdb обычно не enable'ит, добавили
явно с MARK_RAN preCondition если уже есть).
- CREATE INDEX idx_dict_records_data_trgm
ON dictionary_records USING GIN ((data::text) gin_trgm_ops)
- Trigram-based ILIKE с минимум 3 символа в query — index используется.
ILIKE '%pat%' на data::text возвращает любые matches в JSONB serialized.
Backend:
- New RecordSearchQuery (ordinis-domain): native SQL c ROW_NUMBER()
per-dict cap. SQL injection защищён PreparedStatement param + extension
whitelist (allowed_scopes — text[]).
- New SearchController (ordinis-rest-api): GET /api/v1/search?q=&size&perDict.
Default size=100, perDict=10. Min q length=3 (silently empty if shorter).
Scope filtering through ScopeContext — каждый caller видит только свои
scope levels. Results grouped per dict с display name + count + items.
Admin UI:
- New /search route с SearchInput + grouped Panel results.
- URL state ?q=… — share-friendly link "/search?q=SAR-X".
- Per-result link → /dictionaries/{dict}?q={businessKey} для drill-down.
- Min-3 hint, empty state, loading + error.
- New "Поиск" / "Search" tab в navigation header.
- i18n RU (с правильными plurals search.totalHits) + EN.
Behavior:
- Active records only (valid_from <= now() < valid_to).
- Per-dict cap 10 — защита от dominant-dict skew (один dict с 1000 matches
не забьёт результат).
- Total cap 100 — admin "find this code" use case достаточно. Browser-style
search-as-you-type (10k+ matches, instant) — отложен на v2 с dedicated
index server (Elastic / Meili).
Verify:
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
После migration apply'я index size на dictionary_records будет ~30-40%
data column. На текущих 5k records ~3-5 MB, acceptable.
Per-dictionary opt-in для Redis projection materialization. Default false
— projection-writer пропускает event'ы того dict'а, никаких dead writes
в Redis. Включается админ'ом через UI checkbox в Metadata tab.
Migration 0017:
- ALTER TABLE dictionary_definitions ADD COLUMN redis_projection_enabled
BOOLEAN NOT NULL DEFAULT false.
- Index idx_dict_def_redis_proj для быстрого filtering в list queries.
Backend:
- DictionaryDefinition entity: new field + getter/setter.
- DictionaryResponse DTO: returns flag в response.
- CreateDictionaryRequest DTO: optional Boolean (null/missing = no change).
- DictionaryDefinitionService.create + updateSchema: применяет flag из request.
- ProjectionWriter (projection-writer service):
* upsert() — early-return + skip counter если flag=false на dict.
* delete() — symmetric: skip если flag=false (защита от stale keys
после flag-flip; полный cleanup TODO Phase 2).
* New metric: ordinis_projection_records_skipped_total.
Admin UI:
- DictionaryEditorDialog Metadata tab: checkbox "Включить Redis-проекцию"
с описанием. Apply через CreateDictionaryRequest payload.
- DictionaryDefinition + CreateDictionaryRequest types updated.
- i18n RU/EN: schema.redisProjection.{label,hint}.
Что НЕ делается (deferred Phase 2):
- read-api Redis routing — read-api сейчас всегда идёт в PG read replica.
Когда dict опт-инится в флаг, projection материализуется, но read-api
ещё не использует. Read routing — отдельная feature с benchmark proof
(через JMH + k6 SLO test).
- One-shot cleanup job для stale keys после flag-flip from true→false.
Behavior change в production:
- До patch: projection-writer пишет в Redis для ВСЕХ dict events (40
dictionaries). Default flag=false после migration → projection-writer
пропускает всё → Redis keys для existing dicts становятся stale.
Это OK потому что read-api НИКОГДА не читал из Redis (writes были
dead). Stale keys eventually evicted Redis maxmemory policy.
- После patch: только dicts с redis_projection_enabled=true получают
материализацию. По default — нет. Admin opt-ins per-dict через UI.
Verify:
- mvn test (full reactor): SUCCESS, all green.
- mvn -P e2e -pl ordinis-app -am test: 20/20 PASS (migration 0017 applied).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
Phase 4 e2e suite (skipped в local mvn verify, гонится только на CI с
testcontainers) поймал 3 issues. Все исправлены, e2e теперь 20/20 PASS
локально (Postgres testcontainer):
- AuthE2ETest 6/6
- AutoDeriveGeometryE2ETest 6/6
- BitemporalE2ETest 4/4
- OutboxKafkaE2ETest 1/1
- SmokeE2ETest 1/1
- WebhookE2ETest 2/2
Fix#1 — Liquibase XSD ordering (0016 changeset):
- <comment> должен идти ПОСЛЕ <preConditions>. Liquibase XSD строгий:
preConditions → comment → operations.
- "Invalid content was found starting with element preConditions" на
startup → ApplicationContext fail → весь webserver не поднимается.
Fix#2 — JSONB ?-operator vs JDBC parameter substitution (0016):
- Postgres JDBC PreparedStatement обрабатывает любой `?` как $N
placeholder. SQL "prop.value ? 'x-references'" → "prop.value $1
'x-references'" → "syntax error at or near $1". Same для regex с $
anchor.
- Replaced `?` operator на эквивалент `-> 'key' IS NOT NULL`,
drop-or-replace regex anchors на `LIKE '%.%'` (split_part guards
malformed values gracefully).
- Comment в migration документирует ловушку для будущих changesets.
Fix#3 — service size cap mismatch (LineageIndexService):
- CascadeCloseService.evaluatePlan вызывает findRecordDependents с
size=CASCADE_MAX_PER_REQUEST=500. Но findRecordDependents enforced
size 1..200 → IllegalArgumentException → 400 на DELETE record path.
- Bumped service cap до 500. Controller отдельно держит public cap=200
(UI не нуждается в больше).
- Test updated: expects "size 1..500".
Lessons learned:
- Local pre-push discipline должна включать `mvn -P e2e` для миграций.
Не запустил, потому что: e2e требует Docker, медленнее, не было в
routine. Now corrected — будущие migration changes будут идти через
e2e check.
- Liquibase JDBC рабочий принцип: SQL прогоняется через PreparedStatement
→ любой `?` или `$N` ловится. Documenting в migration comment.
Verify:
- mvn -pl ordinis-app -am -P e2e test: BUILD SUCCESS, 20/20 PASS (28s).
- mvn test: 106 unit tests + 20 e2e — все green.
OpenApiConfig:
- Добавлена OAuth2 security scheme (authorization_code flow с Keycloak
realm `nstart`). Auth/token URLs из ordinis.openapi.oauth.issuer-uri
configuration property (default: auth.nstart.space/realms/nstart).
- BearerAuth scheme сохранён secondary — для pre-issued tokens из CI/scripts.
- Servers list переупорядочен — prod первый, staging второй.
- Description обновлён с pointer на /docs/ для full integration guide.
application.yml:
- springdoc.swagger-ui.oauth: client-id (env override), use-pkce-with-
authorization-code-grant=true. Public client `ordinis-swagger`
настраивается отдельно в Keycloak — see keycloak-setup-swagger-client.md
в ordinis-infra repo.
- ORDINIS_OPENAPI_OAUTH_ISSUER_URI env var для override realm на разных
окружениях.
docs/integration/api/read-endpoints.md:
- Swagger UI section обновлён: акцент на Authorize button + Keycloak
PKCE login (no client_secret needed), tip про bearerAuth fallback.
docs/index.md:
- Добавлен tab "Я хочу пощупать API" с link на Swagger UI.
После deploy:
- https://ordinis.k8s.264.nstart.cloud/swagger-ui.html — Swagger UI
- https://ordinis.k8s.264.nstart.cloud/v3/api-docs — OpenAPI spec
Authorize → выбрать oauth2 → Keycloak login (PKCE) → Try it out с JWT.
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.
Покрытие: all-success, classification (record_not_active → skipped,
другие OrdinisException + unexpected → errors), дедуп дубликатов внутри
batch'а, blank/null business_key → skipped invalid_key, передача
at/reason в service, mixed results, empty batch, preservation порядка
(audit consistency).
Mockito не может мокать DictionaryRecordService на Java 17+ (Mockito
inline-mock limitation для классов из standard distribution). Workaround:
анонимный subclass с null deps в конструкторе — переопределённый close()
не обращается к ним. routedStub() helper для per-key поведения через
Map<businessKey, Exception>.
Прогон: 60/60 в rest-api модуле (было 50, +10).
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.
Fix CI flakiness: тест больше не полагается на @Scheduled тики каждые
200ms. NoSchedulingConfig подменяет TaskScheduler на no-op stub —
@Scheduled методы регистрируются, но никогда не запускаются. Тест
вручную дёргает dispatcher.matchTick() / sendTick() — synchronous,
deterministic, нет race с background threads.
Что изменилось:
- @Disabled убран
- Inject WebhookDispatcher, manual matchTick + sendTick после setup
- Await timeouts: 60s → 5s (manual ticks → нет 200ms scheduler delay)
- Pool=4 / send-interval-ms / match-interval-ms убраны (не нужны)
- Send-timeout-ms 1000 → 2000 (с запасом, но scheduler не работает)
- @AutoConfigureMockMvc сохранён, port=RANDOM_PORT, allowed-hosts тот же
Side-fix: @Autowired на Spring constructor WebhookTestPingService
(добавлен ранее package-private constructor для unit-тестов сделал
выбор конструктора неоднозначным; Spring 6 strict — требует
explicit @Autowired когда есть >1 candidate).
Локально: 2/2 PASSED 1.85s (было 9.9s — 5× быстрее).
CI: 14 e2e всех тестов passed (Smoke, OutboxKafka, Auth, Bitemporal,
Webhook).
Покрытие: SSRF reject (private CIDR + non-http scheme), success 2xx,
failure 5xx, network exception, body trim до 200 chars, HMAC signature
header. Нет реальных HTTP запросов — anonymous subclass переопределяет
protected sendRequest().
Refactor: allowedHostsCsv → @Value parameter в конструкторе вместо
@PostConstruct + поле, добавлен package-private constructor для inject
HttpClient/allowedHosts списка. Mockito не может мокать sealed HttpClient
на Java 17+, поэтому через extension point sendRequest().
Lightweight HttpResponse stub реализует только statusCode()+body() без
Mockito.
Записи ground_station и similar dicts имеют lat/lon в data, но
geometryWkt не задан. Spatial filter (AOI) WHERE geometry IS NOT NULL
AND ST_Intersects(...) возвращал 0 records даже когда координаты
есть.
Три места починены:
1. DictionaryRecordService.resolveGeometry — для CRUD через REST.
Если req.geometryWkt() задан — приоритет (explicit). Иначе
читаем data.lat/lon (или latitude/longitude/lng), валидируем
ranges, строим POINT(lon lat) с SRID 4326. Игнор out-of-range
без throw — не ломаем legacy data.
2. CuodBundleImporter.deriveLatLonGeometry — bundle bypass'ит service,
reuse'аем ту же логику локально (5 строк inline). Применяется при
создании НОВЫХ records (existing skipped по businessKey).
3. Liquibase migration 0015-backfill-latlon-geometry — UPDATE existing
records где geometry IS NULL AND data has lat/lon. Три changeset'а
для разных aliases (lat/lon, latitude/longitude, lat/lng). Idempotent
(только NULL→value), rollback supported (NULL обратно).
После deploy: existing 3 ground_station записей (Отрадное и др.) и
spacecraft с lat/lon получат POINT geometry. AOI bbox-фильтр заработает.
Раньше при page=50 admin не мог найти редкий dlq среди
delivered'ов: client-side filter показал бы 1 совпадение из 50,
а на page=1 могли быть ещё. Делаем server-side через ?status=.
Backend:
- WebhookDeliveryRepository.findBySubscriptionIdAndStatusOrderByCreatedAtDesc
— Spring Data derived query, JPA генерирует SQL.
- GET /admin/webhooks/subscriptions/{id}/deliveries?status=dlq —
опциональный param. Invalid status → 400 OrdinisException.
Allowed: pending, retrying, delivered, dlq.
UI:
- 4 chip'а pending/retrying/delivered/dlq над таблицей deliveries
(одинаковый паттерн со scope-чипами в dictionaries).
- Click toggle + reset page=0. 'Сбросить' появляется когда фильтр
активен.
- Status в URL пока не персистится — для одной session-страницы
не критично.
После создания 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.
@Scheduled sweep раз в час (configurable). Walks all schemas, для каждого
x-references marker считает orphans через JdbcTemplate native SQL
(WHERE source.data->>field NOT IN target dict active records).
Метрика: Gauge per (source_dict, source_field, target_dict, target_field).
Cardinality bounded by FK count в bundles (~5 для cuod v1.2.1).
Conditional: ordinis.references.scan-enabled=true (default off, включаем
явно в prod values). Read-only, no write impact.
OrphanReferenceQuery (ordinis-domain): native SQL helper, identifier
validation против SQL injection. Field name regex check т.к. JSONB key
path не bind'ится через @Param.
Tests: 4 в OrphanReferenceScannerTest (parseRef edge cases). Реальный
SQL логику покроет integration тест на staging данных.
Total project tests: 191 → 195.
JSON Schema extension `x-references: "dict_name.field"` объявляет что
значение поля должно ссылаться на active record в указанном словаре.
На POST/PUT record service валидирует existence + scope visibility.
Пример:
{
"type":"object",
"properties":{
"satTypeCode":{
"type":"string",
"x-references":"satellite_type.code"
}
}
}
ReferenceValidator (ordinis-rest-api/service/reference/):
- Walks schema properties, finds x-references markers
- Для каждого ref: lookup target dictionary + active record by JSONB
field value (использует existing findActiveByJsonField repo method)
- Returns ValidationError list, intergrates с RecordValidator pipeline
через DictionaryRecordService.validate()
- Scope hide: target dict не accessible → "not_found" error (не
"scope_denied"), чтобы не leak'ало existence
- Optional поля: если value missing, skip (JSON Schema validation
отдельно отлавливает required)
Error codes:
- x_references_malformed (spec не "dict.field")
- x_references_dict_not_found (target dictionary не существует)
- x_references_target_not_found (referenced record нет / scope hidden)
- x_references_non_string (v1 поддерживает только string values)
v1 ограничения (документированы в JavaDoc):
- Top-level fields only (nested objects не поддержаны)
- Only string values
- No cascade on delete/close (orphan FK alert — v2.5)
Tests (15 в ReferenceValidatorTest):
- parseRef: valid, empty, missing dot, trailing dot, nested paths
- validate: no refs, missing value (optional skip), existing record OK,
missing record error, missing dictionary error, scope-hidden error,
non-string value error, malformed spec error, multiple fields
validated independently, null schema graceful
Total project tests: 176 → 191.
Note: Mockito не может mock entity classes на JDK 25 (subclass
mock-maker limitation). Используем real entity ctor для
DictionaryDefinition + DictionaryRecord в тестах.