From e02e684cca40da1b855dbc229ea876e5ffe7c2f8 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 23:08:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/dictionaries/{name}/snapsho?= =?UTF-8?q?ts=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); + } +}