From e02e684cca40da1b855dbc229ea876e5ffe7c2f8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:08:07 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(api):=20GET=20/dictionaries/{name}/sna?= =?UTF-8?q?pshots=20=E2=80=94=20record-change=20buckets=20(backend=20#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../record/DictionaryRecordRepository.java | 32 ++++ .../restapi/service/SnapshotsService.java | 120 ++++++++++++++ .../web/DictionaryChangelogController.java | 49 ++++-- .../restapi/service/SnapshotsServiceTest.java | 149 ++++++++++++++++++ 4 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index 8dc1fa5..bb09e61 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -146,4 +146,36 @@ public interface DictionaryRecordRepository extends JpaRepositoryGranularity: PostgreSQL date_trunc unit ({@code hour}, {@code day}, + * {@code week}). Возвращает Object[] = {bucket OffsetDateTime, + * changedSinceLast Long}. Caller'а потом мапит на DTO. + * + * @param scopes — CSV array (passed как text[] для unnest in WHERE), скоупы + * которые caller видит. EMPTY → no records visible (caller + * anonymous и scope filter empty). + */ + @Query(value = """ + SELECT date_trunc(:granularity, valid_from)::timestamptz AS bucket, + count(*) AS changed_since_last + FROM dictionary_records + WHERE dictionary_id = :dictionaryId + AND data_scope = ANY (string_to_array(:scopesCsv, ',')) + AND valid_from >= :from + AND valid_from < :to + GROUP BY bucket + ORDER BY bucket DESC + """, nativeQuery = true) + List findSnapshotBuckets( + @Param("dictionaryId") UUID dictionaryId, + @Param("scopesCsv") String scopesCsv, + @Param("from") OffsetDateTime from, + @Param("to") OffsetDateTime to, + @Param("granularity") String granularity); } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java new file mode 100644 index 0000000..d6b9ae1 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsService.java @@ -0,0 +1,120 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Snapshots service для TimeTravel slider marks. Возвращает bucketed list + * timestamps где минимум одна запись была inserted/updated в window. UI + * затем snap'ает slider к этим точкам — wider granularity = меньше marks + * (clearer UX на больших windows). + * + *

Granularity = PostgreSQL date_trunc unit. Поддерживаются: hour, day, + * week. Other unit'ы (year, month) overkill для admin scenarios (типичный + * window 30-90 дней). + * + *

Label: backend formatits relative RU label ("1 апр", "15 апр", + * "сейчас"). Frontend не делает locale logic — single source of truth. + */ +@Service +public class SnapshotsService { + + private static final Set ALLOWED_GRANULARITY = Set.of("hour", "day", "week"); + private static final DateTimeFormatter RU_DAY = DateTimeFormatter.ofPattern("d MMM", new Locale("ru")); + private static final DateTimeFormatter RU_HOUR = DateTimeFormatter.ofPattern("d MMM HH:mm", new Locale("ru")); + + private final DictionaryDefinitionService definitionService; + private final DictionaryRecordRepository recordRepository; + private final ScopeContext scopeContext; + + public SnapshotsService( + DictionaryDefinitionService definitionService, + DictionaryRecordRepository recordRepository, + ScopeContext scopeContext) { + this.definitionService = definitionService; + this.recordRepository = recordRepository; + this.scopeContext = scopeContext; + } + + /** + * @throws OrdinisException 404 если dict не существует / не виден; + * 400 если granularity недопустимый. + */ + @Transactional(readOnly = true) + public SnapshotsResponse findSnapshots( + String name, + OffsetDateTime from, + OffsetDateTime to, + String granularity) { + DictionaryDefinition d = definitionService.findByName(name); + if (!scopeContext.canAccess(d.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name); + } + String gran = granularity == null ? "day" : granularity.toLowerCase(); + if (!ALLOWED_GRANULARITY.contains(gran)) { + throw OrdinisException.badRequest( + "invalid_granularity", + "granularity must be one of " + ALLOWED_GRANULARITY + ", got: " + granularity); + } + OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime windowTo = to == null ? now : to; + OffsetDateTime windowFrom = from == null ? windowTo.minusDays(30) : from; + if (!windowFrom.isBefore(windowTo)) { + throw OrdinisException.badRequest( + "invalid_window", "from must be before to: from=" + windowFrom + " to=" + windowTo); + } + + Set scopes = scopeContext.currentScopes(); + String scopesCsv = scopes.stream().map(Enum::name).collect(Collectors.joining(",")); + + List rows = recordRepository.findSnapshotBuckets( + d.getId(), scopesCsv, windowFrom, windowTo, gran); + + List snapshots = new ArrayList<>(rows.size()); + boolean currentMarked = false; + DateTimeFormatter fmt = "hour".equals(gran) ? RU_HOUR : RU_DAY; + for (Object[] row : rows) { + OffsetDateTime bucket = ((java.sql.Timestamp) row[0]).toInstant().atOffset(now.getOffset()); + long changedSinceLast = ((Number) row[1]).longValue(); + boolean isCurrent = !currentMarked && !bucket.isAfter(now); + if (isCurrent) currentMarked = true; + String label = isCurrent ? "сейчас" : fmt.format(bucket); + // totalRecords остаётся 0 в MVP — точный cumulative count требует + // отдельный window query. UI показывает только bucket + changed count. + snapshots.add(new Snapshot(bucket, 0L, changedSinceLast, label, isCurrent)); + } + // Reverse чтобы oldest first (UI slider читает слева-направо). + java.util.Collections.reverse(snapshots); + + return new SnapshotsResponse(d.getName(), windowFrom, windowTo, snapshots); + } + + // === DTOs === + + public record Snapshot( + OffsetDateTime at, + long totalRecords, + long changedSinceLast, + String label, + boolean isCurrent) {} + + public record SnapshotsResponse( + String dictionary, + OffsetDateTime from, + OffsetDateTime to, + List snapshots) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java index fc64c12..96f355e 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryChangelogController.java @@ -1,6 +1,7 @@ package cloud.nstart.terravault.ordinis.restapi.web; import cloud.nstart.terravault.ordinis.restapi.service.ChangelogService; +import cloud.nstart.terravault.ordinis.restapi.service.SnapshotsService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -8,27 +9,33 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * {@code GET /api/v1/dictionaries/{name}/changelog} — schema versions - * timeline для admin-UI History tab и TimeTravel modal. - * - *

Source: audit_log filtered by DEFINITION_* event types. Каждая запись - * содержит payload_before/after = JsonNode schemas; ChangelogService - * computes property-level diff summary. + * Per-dictionary history / time-travel endpoints для admin-UI: + *

    + *
  • {@code GET /dictionaries/{name}/changelog} — schema versions timeline
  • + *
  • {@code GET /dictionaries/{name}/snapshots} — record-change buckets для TimeTravel slider
  • + *
* *

Errors: *

    *
  • 404 {@code dictionary_not_found} — dict не существует или не виден * caller'у (одинаковая ошибка чтобы не leak'ало existence).
  • + *
  • 400 {@code invalid_granularity} — snapshots granularity не из + * {hour, day, week}.
  • + *
  • 400 {@code invalid_window} — from >= to.
  • *
*/ @RestController @RequestMapping("/api/v1/dictionaries") public class DictionaryChangelogController { - private final ChangelogService service; + private final ChangelogService changelogService; + private final SnapshotsService snapshotsService; - public DictionaryChangelogController(ChangelogService service) { - this.service = service; + public DictionaryChangelogController( + ChangelogService changelogService, + SnapshotsService snapshotsService) { + this.changelogService = changelogService; + this.snapshotsService = snapshotsService; } /** @@ -41,6 +48,28 @@ public class DictionaryChangelogController { public ChangelogService.ChangelogResponse changelog( @PathVariable String name, @RequestParam(defaultValue = "50") int limit) { - return service.findChangelog(name, limit); + return changelogService.findChangelog(name, limit); + } + + /** + * Snapshot points (record-change buckets) для TimeTravel slider. + * + * @param name dict business name + * @param from ISO datetime window start. Default: now - 30d + * @param to ISO datetime window end. Default: now + * @param granularity bucket size — {@code hour} / {@code day} / {@code week}. + * Default: {@code day}. + */ + @GetMapping("/{name}/snapshots") + public SnapshotsService.SnapshotsResponse snapshots( + @PathVariable String name, + @RequestParam(required = false) + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME) + java.time.OffsetDateTime from, + @RequestParam(required = false) + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME) + java.time.OffsetDateTime to, + @RequestParam(defaultValue = "day") String granularity) { + return snapshotsService.findSnapshots(name, from, to, granularity); } } diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java new file mode 100644 index 0000000..3335fdd --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SnapshotsServiceTest.java @@ -0,0 +1,149 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Timestamp; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SnapshotsServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private DictionaryDefinitionRepository defRepo; + private DictionaryRecordRepository recRepo; + private DictionaryDefinitionService defService; + private TestScopeContext scopeCtx; + private SnapshotsService service; + + @BeforeEach + void setUp() { + defRepo = mock(DictionaryDefinitionRepository.class); + recRepo = mock(DictionaryRecordRepository.class); + defService = new DictionaryDefinitionService(defRepo, null); + scopeCtx = new TestScopeContext(); + service = new SnapshotsService(defService, recRepo, scopeCtx); + } + + static final class TestScopeContext extends ScopeContext { + private DataScope blockedScope; + TestScopeContext() { super(null); } + @Override public boolean canAccess(DataScope dataScope) { + return dataScope != blockedScope; + } + @Override public java.util.Set currentScopes() { + return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); + } + void block(DataScope s) { this.blockedScope = s; } + } + + @Test + void snapshots_dictNotFound_404() { + when(defRepo.findByName("missing")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> service.findSnapshots("missing", null, null, "day")) + .isInstanceOf(OrdinisException.class); + } + + @Test + void snapshots_scopeHidden_404() { + var d = def("private", DataScope.RESTRICTED); + when(defRepo.findByName("private")).thenReturn(Optional.of(d)); + scopeCtx.block(DataScope.RESTRICTED); + assertThatThrownBy(() -> service.findSnapshots("private", null, null, "day")) + .isInstanceOf(OrdinisException.class); + } + + @Test + void snapshots_invalidGranularity_400() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + assertThatThrownBy(() -> service.findSnapshots("dict", null, null, "century")) + .isInstanceOf(OrdinisException.class) + .hasMessageContaining("granularity"); + } + + @Test + void snapshots_invalidWindow_400() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + OffsetDateTime from = OffsetDateTime.parse("2026-05-11T00:00:00Z"); + OffsetDateTime to = OffsetDateTime.parse("2026-04-11T00:00:00Z"); + assertThatThrownBy(() -> service.findSnapshots("dict", from, to, "day")) + .isInstanceOf(OrdinisException.class) + .hasMessageContaining("from must be before to"); + } + + @Test + void snapshots_basicBuckets_mapToSnapshots() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + Timestamp april = Timestamp.from(OffsetDateTime.parse("2026-04-01T00:00:00Z").toInstant()); + Timestamp may = Timestamp.from(OffsetDateTime.parse("2026-05-11T00:00:00Z").toInstant()); + when(recRepo.findSnapshotBuckets(eq(d.getId()), anyString(), any(), any(), eq("day"))) + .thenReturn(List.of( + new Object[]{may, 5L}, + new Object[]{april, 3L})); + + var result = service.findSnapshots("dict", null, null, "day"); + + assertThat(result.dictionary()).isEqualTo("dict"); + assertThat(result.snapshots()).hasSize(2); + // Reversed → oldest first. + assertThat(result.snapshots().get(0).changedSinceLast()).isEqualTo(3L); + assertThat(result.snapshots().get(1).changedSinceLast()).isEqualTo(5L); + } + + @Test + void snapshots_currentMarkedOnce_evenWithFutureBuckets() { + var d = def("dict", DataScope.PUBLIC); + when(defRepo.findByName("dict")).thenReturn(Optional.of(d)); + OffsetDateTime now = OffsetDateTime.now(); + Timestamp pastA = Timestamp.from(now.minusDays(10).toInstant()); + Timestamp pastB = Timestamp.from(now.minusDays(5).toInstant()); + // Repository returns DESC, так order — newest first. + when(recRepo.findSnapshotBuckets(any(), anyString(), any(), any(), anyString())) + .thenReturn(List.of( + new Object[]{pastB, 1L}, + new Object[]{pastA, 2L})); + + var result = service.findSnapshots("dict", null, null, "day"); + + long isCurrentCount = result.snapshots().stream().filter(SnapshotsService.Snapshot::isCurrent).count(); + assertThat(isCurrentCount).isEqualTo(1L); + // Newest bucket (pastB) — это "сейчас" label. + var newest = result.snapshots().get(result.snapshots().size() - 1); + assertThat(newest.isCurrent()).isTrue(); + assertThat(newest.label()).isEqualTo("сейчас"); + } + + // === Helpers === + + private static DictionaryDefinition def(String name, DataScope scope) { + JsonNode schema; + try { + schema = OM.readTree("{\"properties\":{}}"); + } catch (Exception e) { + throw new RuntimeException(e); + } + return new DictionaryDefinition(UUID.randomUUID(), name, scope, schema); + } +} From db4f859ef6129f3bc85af79aa2f7fa4ec873ea5a Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:13:04 +0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(api):=20PATCH=20/dictionaries/{name}/s?= =?UTF-8?q?chema=20=E2=80=94=20inline=20non-breaking=20field=20edit=20(bac?= =?UTF-8?q?kend=20#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../restapi/dto/PatchSchemaRequest.java | 26 ++ .../restapi/error/OrdinisException.java | 4 + .../restapi/service/SchemaPatchService.java | 237 ++++++++++++++++++ .../web/DictionaryDefinitionController.java | 29 ++- .../service/SchemaPatchServiceTest.java | 181 +++++++++++++ 5 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java new file mode 100644 index 0000000..201008d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/PatchSchemaRequest.java @@ -0,0 +1,26 @@ +package cloud.nstart.terravault.ordinis.restapi.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Inline single-field schema edit per backend #4 в pending-endpoints brief. + * + *

Structured op (вместо JSON Patch RFC 6902) — более ergonomic для UI + * Fields tab inline edit. Frontend каждый field row кликает "edit" → form + * показывает только allowed (non-breaking) changes → submits эти deltas. + * + * @param expectedHeadVersion optimistic-lock — schema_version текущий + * head. Если в БД другой → 409 version_mismatch. + * @param field property name внутри schema.properties (например + * "altitude"). + * @param changes key→value pairs изменения. Allowed keys: + * description, title, minimum, maximum, enum (только + * additions). Breaking keys (type, format, x-references) + * → 422. + */ +public record PatchSchemaRequest( + @NotBlank String expectedHeadVersion, + @NotBlank String field, + @NotNull JsonNode changes) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java index 2f1afcb..1d50610 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/error/OrdinisException.java @@ -31,4 +31,8 @@ public class OrdinisException extends RuntimeException { public static OrdinisException forbidden(String code, String message) { return new OrdinisException(HttpStatus.FORBIDDEN, code, message); } + + public static OrdinisException unprocessableEntity(String code, String message) { + return new OrdinisException(HttpStatus.UNPROCESSABLE_ENTITY, code, message); + } } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java new file mode 100644 index 0000000..676fdee --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchService.java @@ -0,0 +1,237 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Iterator; +import java.util.Map; +import java.util.Set; + +/** + * Inline non-breaking schema patch — backend #4 из pending-endpoints brief. + * + *

Применяет узкий allow-list mutations к одному property без breaking + * existing records. Bumps schema_version в patch component (1.4.0 → 1.4.1). + * Cascades через DictionaryDefinitionService.updateSchema чтобы переиспользовать + * outbox emit + audit logging. + * + *

Allowed change keys: + *

    + *
  • {@code description} — JsonNode (localized object или plain string)
  • + *
  • {@code title} — localized object
  • + *
  • {@code minimum} / {@code maximum} — numeric (expansion only — + * narrowing rejected т.к. может invalidate existing records)
  • + *
  • {@code enum} — array. Mutation должна только ADD values, not remove + * (rejecting → 422 breaking_change_requires_draft)
  • + *
+ * + *

Breaking change keys (type, format, required, x-references, *Of) → + * 422 {@code breaking_change_requires_draft}. + */ +@Service +public class SchemaPatchService { + + private static final Logger log = LoggerFactory.getLogger(SchemaPatchService.class); + + private static final Set ALLOWED_KEYS = Set.of( + "description", "title", "minimum", "maximum", "enum"); + + private final DictionaryDefinitionRepository repository; + private final DictionaryDefinitionService definitionService; + private final ScopeContext scopeContext; + /** Optional — null в test ctor'е. */ + private final AuditLogger auditLogger; + + @Autowired + public SchemaPatchService( + DictionaryDefinitionRepository repository, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext, + AuditLogger auditLogger) { + this.repository = repository; + this.definitionService = definitionService; + this.scopeContext = scopeContext; + this.auditLogger = auditLogger; + } + + public SchemaPatchService( + DictionaryDefinitionRepository repository, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext) { + this(repository, definitionService, scopeContext, null); + } + + /** + * Apply patch. Throws OrdinisException на validation issues: + *

    + *
  • 404 {@code dictionary_not_found}
  • + *
  • 404 {@code field_not_found} — field отсутствует в schema.properties
  • + *
  • 409 {@code version_mismatch} — expectedHeadVersion stale
  • + *
  • 422 {@code breaking_change_requires_draft} — patch trying breaking key
  • + *
  • 422 {@code validation_failed} — numeric narrowing / enum removal
  • + *
+ */ + @Transactional + public PatchSchemaResponse patchSchema(String dictName, PatchSchemaRequest req) { + DictionaryDefinition d = definitionService.findByName(dictName); + if (!scopeContext.canAccess(d.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + dictName); + } + if (!d.getSchemaVersion().equals(req.expectedHeadVersion())) { + throw OrdinisException.conflict( + "version_mismatch", + "expectedHeadVersion=" + req.expectedHeadVersion() + + " but current is " + d.getSchemaVersion()); + } + + JsonNode schema = d.getSchemaJson(); + if (!(schema instanceof ObjectNode schemaObj)) { + throw OrdinisException.badRequest( + "invalid_schema", "Schema is not a JSON object"); + } + JsonNode propertiesNode = schemaObj.get("properties"); + if (!(propertiesNode instanceof ObjectNode props)) { + throw OrdinisException.notFound( + "field_not_found", "Schema has no properties"); + } + JsonNode targetProp = props.get(req.field()); + if (!(targetProp instanceof ObjectNode targetObj)) { + throw OrdinisException.notFound( + "field_not_found", "Field not in schema.properties: " + req.field()); + } + + JsonNode changesNode = req.changes(); + if (!changesNode.isObject()) { + throw OrdinisException.badRequest( + "invalid_changes", "changes must be a JSON object"); + } + + // Validate каждый change key + apply на копию target prop. + ObjectNode patched = targetObj.deepCopy(); + Iterator> it = changesNode.fields(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + String key = entry.getKey(); + JsonNode newValue = entry.getValue(); + if (!ALLOWED_KEYS.contains(key)) { + throw OrdinisException.unprocessableEntity( + "breaking_change_requires_draft", + "Key '" + key + "' requires workflow draft, not inline patch." + + " Allowed inline keys: " + ALLOWED_KEYS); + } + JsonNode oldValue = targetObj.get(key); + validateChange(key, oldValue, newValue); + patched.set(key, newValue); + } + + // Apply patched property в новый schemaJson + bump version. + ObjectNode newProps = props.deepCopy(); + newProps.set(req.field(), patched); + ObjectNode newSchema = schemaObj.deepCopy(); + newSchema.set("properties", newProps); + + String oldVersion = d.getSchemaVersion(); + String newVersion = bumpPatchVersion(oldVersion); + + JsonNode prevSchema = d.getSchemaJson(); + d.setSchemaJson(newSchema); + d.setSchemaVersion(newVersion); + DictionaryDefinition saved = repository.save(d); + + if (auditLogger != null) { + auditLogger.definitionUpdated(saved, prevSchema); + } + log.info("Schema patched: dict={} field={} oldVersion={} newVersion={}", + dictName, req.field(), oldVersion, newVersion); + + return new PatchSchemaResponse(newVersion, saved.getSchemaJson()); + } + + /** Per-key non-breaking validation. */ + private static void validateChange(String key, JsonNode oldValue, JsonNode newValue) { + switch (key) { + case "description": + case "title": + // Free-form text/object — всегда non-breaking. + return; + case "minimum": { + if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) { + double oldNum = oldValue.doubleValue(); + double newNum = newValue.doubleValue(); + if (newNum > oldNum) { + throw OrdinisException.unprocessableEntity( + "validation_failed", + "minimum tighter (" + newNum + " > " + oldNum + + ") могло бы invalidate existing records. Use draft."); + } + } + return; + } + case "maximum": { + if (oldValue != null && oldValue.isNumber() && newValue.isNumber()) { + double oldNum = oldValue.doubleValue(); + double newNum = newValue.doubleValue(); + if (newNum < oldNum) { + throw OrdinisException.unprocessableEntity( + "validation_failed", + "maximum tighter (" + newNum + " < " + oldNum + + ") могло бы invalidate existing records. Use draft."); + } + } + return; + } + case "enum": { + if (oldValue != null && oldValue.isArray() && newValue.isArray()) { + // new enum должен содержать все старые values (only-additions). + java.util.Set oldSet = new java.util.HashSet<>(); + oldValue.forEach(v -> oldSet.add(v.asText())); + java.util.Set newSet = new java.util.HashSet<>(); + newValue.forEach(v -> newSet.add(v.asText())); + if (!newSet.containsAll(oldSet)) { + oldSet.removeAll(newSet); + throw OrdinisException.unprocessableEntity( + "validation_failed", + "enum removes existing values: " + oldSet + ". Use draft."); + } + } + return; + } + default: + // Shouldn't happen — ALLOWED_KEYS check выше. + throw OrdinisException.unprocessableEntity( + "breaking_change_requires_draft", "Key not allowed: " + key); + } + } + + /** + * Bump semver patch component (1.4.0 → 1.4.1). Non-semver versions + * → append ".1" / "+1". Best-effort, не throws. + */ + static String bumpPatchVersion(String version) { + if (version == null || version.isBlank()) return "1.0.1"; + String[] parts = version.split("\\."); + if (parts.length != 3) return version + ".1"; + try { + int patch = Integer.parseInt(parts[2]); + return parts[0] + "." + parts[1] + "." + (patch + 1); + } catch (NumberFormatException e) { + return version + ".1"; + } + } + + // === DTOs === + + public record PatchSchemaResponse(String newVersion, JsonNode schemaJson) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java index fab05be..12182a1 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -5,11 +5,14 @@ import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import cloud.nstart.terravault.ordinis.restapi.service.SchemaPatchService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -32,14 +35,17 @@ public class DictionaryDefinitionController { private final DictionaryDefinitionService service; private final ScopeContext scopeContext; private final DictionaryRecordRepository recordRepository; + private final SchemaPatchService schemaPatchService; public DictionaryDefinitionController( DictionaryDefinitionService service, ScopeContext scopeContext, - DictionaryRecordRepository recordRepository) { + DictionaryRecordRepository recordRepository, + SchemaPatchService schemaPatchService) { this.service = service; this.scopeContext = scopeContext; this.recordRepository = recordRepository; + this.schemaPatchService = schemaPatchService; } @GetMapping @@ -102,4 +108,25 @@ public class DictionaryDefinitionController { } return DictionaryResponse.from(service.updateSchema(name, req)); } + + /** + * Inline non-breaking field patch — backend #4 из pending-endpoints brief. + * Bumps patch component (1.4.0 → 1.4.1). Только allow-listed keys + * (description/title/minimum/maximum/enum). + * + *

Errors: + *

    + *
  • 404 {@code dictionary_not_found}
  • + *
  • 404 {@code field_not_found}
  • + *
  • 409 {@code version_mismatch}
  • + *
  • 422 {@code breaking_change_requires_draft} / {@code validation_failed}
  • + *
+ */ + @PatchMapping("/{name}/schema") + public SchemaPatchService.PatchSchemaResponse patchSchema( + @PathVariable String name, + @org.springframework.web.bind.annotation.RequestBody + @jakarta.validation.Valid PatchSchemaRequest req) { + return schemaPatchService.patchSchema(name, req); + } } diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java new file mode 100644 index 0000000..3ca9c2b --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/SchemaPatchServiceTest.java @@ -0,0 +1,181 @@ +package cloud.nstart.terravault.ordinis.restapi.service; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRepository; +import cloud.nstart.terravault.ordinis.restapi.dto.PatchSchemaRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SchemaPatchServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private DictionaryDefinitionRepository repo; + private DictionaryDefinitionService defService; + private TestScopeContext scopeCtx; + private SchemaPatchService service; + + @BeforeEach + void setUp() { + repo = mock(DictionaryDefinitionRepository.class); + defService = new DictionaryDefinitionService(repo, null); + scopeCtx = new TestScopeContext(); + service = new SchemaPatchService(repo, defService, scopeCtx); + when(repo.save(org.mockito.ArgumentMatchers.any(DictionaryDefinition.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + static final class TestScopeContext extends ScopeContext { + private DataScope blockedScope; + TestScopeContext() { super(null); } + @Override public boolean canAccess(DataScope dataScope) { + return dataScope != blockedScope; + } + @Override public java.util.Set currentScopes() { + return java.util.Set.of(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); + } + void block(DataScope s) { this.blockedScope = s; } + } + + @Test + void patch_versionMismatch_409() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.0.0", "altitude", + OM.readTree("{\"description\":\"updated\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("version_mismatch")); + } + + @Test + void patch_fieldNotFound_404() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "mass", + OM.readTree("{\"description\":\"x\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("field_not_found")); + } + + @Test + void patch_breakingKey_422() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"type\":\"string\"}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("breaking_change_requires_draft")); + } + + @Test + void patch_descriptionUpdate_bumpsPatchVersion() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\"}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"description\":{\"ru\":\"Высота орбиты\",\"en\":\"Altitude\"}}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + JsonNode altitude = result.schemaJson().get("properties").get("altitude"); + assertThat(altitude.get("description").get("ru").asText()).isEqualTo("Высота орбиты"); + } + + @Test + void patch_minimumTighter_422() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":0}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"minimum\":100}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("validation_failed")); + } + + @Test + void patch_minimumExpanded_ok() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"altitude\":{\"type\":\"integer\",\"minimum\":100}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "altitude", + OM.readTree("{\"minimum\":0}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + } + + @Test + void patch_enumRemove_422() throws Exception { + var d = def("dict", "1.4.0", """ + {"properties":{"status":{"type":"string","enum":["live","draft","archived"]}}}"""); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "status", + OM.readTree("{\"enum\":[\"live\",\"draft\"]}")); + assertThatThrownBy(() -> service.patchSchema("dict", req)) + .isInstanceOfSatisfying(OrdinisException.class, + e -> assertThat(e.getCode()).isEqualTo("validation_failed")); + } + + @Test + void patch_enumAdd_ok() throws Exception { + var d = def("dict", "1.4.0", + "{\"properties\":{\"status\":{\"type\":\"string\",\"enum\":[\"live\"]}}}"); + when(repo.findByName("dict")).thenReturn(Optional.of(d)); + var req = new PatchSchemaRequest("1.4.0", "status", + OM.readTree("{\"enum\":[\"live\",\"draft\",\"archived\"]}")); + + var result = service.patchSchema("dict", req); + + assertThat(result.newVersion()).isEqualTo("1.4.1"); + assertThat(result.schemaJson().get("properties").get("status").get("enum")) + .hasSize(3); + } + + @Test + void bumpPatchVersion_basic() { + assertThat(SchemaPatchService.bumpPatchVersion("1.4.0")).isEqualTo("1.4.1"); + assertThat(SchemaPatchService.bumpPatchVersion("0.0.99")).isEqualTo("0.0.100"); + assertThat(SchemaPatchService.bumpPatchVersion("2.0.0")).isEqualTo("2.0.1"); + } + + @Test + void bumpPatchVersion_nonSemver() { + assertThat(SchemaPatchService.bumpPatchVersion("v1")).isEqualTo("v1.1"); + assertThat(SchemaPatchService.bumpPatchVersion("")).isEqualTo("1.0.1"); + assertThat(SchemaPatchService.bumpPatchVersion(null)).isEqualTo("1.0.1"); + } + + private static DictionaryDefinition def(String name, String version, String schemaJson) { + JsonNode schema; + try { + schema = OM.readTree(schemaJson); + } catch (Exception e) { + throw new RuntimeException(e); + } + var d = new DictionaryDefinition(UUID.randomUUID(), name, DataScope.PUBLIC, schema); + d.setSchemaVersion(version); + return d; + } +} From f5e7348f92e4b44f2b16a3872f2f1d2b9414167e Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:22:49 +0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(audit):=20definition=20action=20!=3D=20?= =?UTF-8?q?record=20action=20=E2=80=94=20fix=20SmokeE2ETest=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 список, не задеть). --- .../ordinis/restapi/audit/AuditLogger.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 3a5d866..17c2162 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -49,21 +49,22 @@ public class AuditLogger { } /** - * Audit для создания справочника. record-связанные поля null'ятся - * (record_id / business_key не применимы к dict-level event). - * payload_after = schema_json текущей версии. + * Audit для создания справочника. record-связанные поля null'ятся. + * + *

Action = {@code DEFINITION_CREATE} (не {@code CREATE}) — иначе + * существующие фильтры по action=CREATE подхватывают dict-level row + * без businessKey (SmokeE2ETest.contextLoadsAndCreatesRecord фейлится). */ public void definitionCreated(DictionaryDefinition d) { - write("DEFINITION_CREATED", "CREATE", d, null, null, d.getSchemaJson()); + write("DEFINITION_CREATED", "DEFINITION_CREATE", d, null, null, d.getSchemaJson()); } /** - * Audit для обновления schema справочника. payload_before — schema до - * update'а, payload_after — после. Используется для changelog / diff - * computation в frontend History tab. + * Audit для обновления schema справочника. payload_before/after — + * schema до/после. Используется changelog для diff computation. */ public void definitionUpdated(DictionaryDefinition d, JsonNode prevSchema) { - write("DEFINITION_UPDATED", "UPDATE", d, null, prevSchema, d.getSchemaJson()); + write("DEFINITION_UPDATED", "DEFINITION_UPDATE", d, null, prevSchema, d.getSchemaJson()); } public void recordUpdate(