feat(api): GET /dictionaries/{name}/snapshots — record-change buckets (backend #2)

Backend #2 из pending-endpoints brief. Frontend TimeTravelModal использует
этот endpoint для slider marks — каждая точка = bucket где минимум одна
запись validFrom попадает.

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

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

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

Frontend impact: TimeTravelModal slider marks переходит с recordTimestamps
derived data на реальный API. ~1h frontend swap.
This commit is contained in:
Zimin A.N.
2026-05-11 23:08:07 +03:00
parent ebc90d76e9
commit e02e684cca
4 changed files with 340 additions and 10 deletions
@@ -146,4 +146,36 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("scopesCsv") String[] scopesCsv,
@Param("at") OffsetDateTime at,
@Param("polygonGeoJson") String polygonGeoJson);
/**
* Bucketed list snapshot points: timestamps когда records появлялись
* (validFrom) в заданном time window. Используется для TimeTravel
* slider marks на admin-UI — каждая mark = "interesting" moment где
* минимум одна запись changed.
*
* <p>Granularity: 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<Object[]> findSnapshotBuckets(
@Param("dictionaryId") UUID dictionaryId,
@Param("scopesCsv") String scopesCsv,
@Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to,
@Param("granularity") String granularity);
}