feat(approval): Approval Workflow v2 Phase 4 monitoring + pending-review badges

Approval Workflow v2 — Phase 4: monitoring + UX visibility.

Backend metrics (Micrometer → Prometheus):
- Gauge nsi_draft_pending_total — global queue depth (polled на scrape).
- Counter nsi_draft_submit_total{dictionary,operation,outcome=ok|already_pending|error}
  — submit attempts с разбивкой по outcome. Phase 3 lessons: error outcome
  выявит schema bugs которые ранее маскировались.
- Counter nsi_draft_decision_total{dictionary,operation,outcome=approved|rejected|withdrawn}
  — counts decisions, разбит по типу.
- Timer nsi_draft_review_duration_seconds — time-to-decide histogram (от
  submit до approve/reject/withdraw). publishPercentileHistogram() — для
  Prometheus histogram_quantile p95/p99.

DraftService:
- Optional<MeterRegistry> — null-safe в test ctor'ах.
- @PostConstruct registerGauges() — Gauge.builder polled от
  RecordDraftRepository.countPending().
- recordDecision() helper в approve/reject/withdraw — записывает Timer +
  Counter одной точкой.
- 4-arg ctor compat для Phase 1 unit tests.

UI (admin):
- useDictPendingDrafts — per-dict pending list, refetch 30s, gated на
  detailQuery.data.approvalRequired (no-op для не-approval dicts).
- "На review" badge в businessKey cell records table — моментальная
  видимость что есть pending draft. Open /reviews для act'а.
- i18n RU/EN: dict.pendingReview.label + tooltip.

Domain:
- RecordDraftRepository.countPending() — для Gauge polled count.

Tests: ordinis-rest-api unit 119/119, ordinis-app e2e 28/28, vitest 89/89.
This commit is contained in:
Zimin A.N.
2026-05-08 15:11:25 +03:00
parent d229c545f9
commit b2e7fd84c9
6 changed files with 158 additions and 8 deletions
@@ -11,6 +11,11 @@ import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import jakarta.annotation.PostConstruct;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
@@ -24,8 +29,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
@@ -53,25 +60,51 @@ public class DraftService {
private final DictionaryDefinitionService definitionService;
private final DictionaryRecordService recordService;
private final ScopeContext scopeContext;
/** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */
private final Optional<MeterRegistry> meterRegistry;
@org.springframework.beans.factory.annotation.Autowired
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext) {
ScopeContext scopeContext,
Optional<MeterRegistry> meterRegistry) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.recordService = recordService;
this.scopeContext = scopeContext;
this.meterRegistry = meterRegistry;
}
/** Test-friendly ctor — без recordService для unit tests submit/list flow. */
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, null, scopeContext);
this(draftRepo, definitionService, null, scopeContext, Optional.empty());
}
/** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
public DraftService(
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty());
}
/**
* Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer
* вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick.
* Phase 4 monitoring: queue depth → soak observability + alerting.
*/
@PostConstruct
void registerGauges() {
meterRegistry.ifPresent(reg ->
Gauge.builder("nsi_draft_pending_total", draftRepo, RecordDraftRepository::countPending)
.description("Approval workflow: pending drafts ожидающих review (D3 pool model)")
.register(reg));
}
/**
@@ -108,6 +141,7 @@ public class DraftService {
RecordDraft saved = draftRepo.saveAndFlush(draft);
log.info("Draft submitted: dict={} bk={} op={} maker={}",
dictionaryName, req.businessKey(), req.operation(), makerId);
countSubmit(dictionaryName, req.operation(), "ok");
return saved;
} catch (DataIntegrityViolationException ex) {
// SQLSTATE 23P01 = exclusion_violation (record_drafts_one_pending_per_key).
@@ -115,10 +149,12 @@ public class DraftService {
// иначе скрываем баги схемы (Phase 3 lessons learned: enum case mismatch
// прятался в маске draft_already_pending).
if (isExclusionViolation(ex)) {
countSubmit(dictionaryName, req.operation(), "already_pending");
throw OrdinisException.conflict(
"draft_already_pending",
"На запись business_key=" + req.businessKey() + " уже есть pending draft.");
}
countSubmit(dictionaryName, req.operation(), "error");
throw ex;
}
}
@@ -203,12 +239,14 @@ public class DraftService {
// Mark approved (kept в таблице — D4 audit trail). Delete не делаем —
// draft.id остаётся ссылочно валидным (audit log → draft_id).
OffsetDateTime now = OffsetDateTime.now();
draft.setStatus(DraftStatus.APPROVED);
draft.setReviewerId(reviewerId);
draft.setReviewedAt(OffsetDateTime.now());
draft.setReviewedAt(now);
draft.setReviewComment(reviewComment);
RecordDraft saved = draftRepo.saveAndFlush(draft);
recordDecision(def.getName(), draft.getOperation(), "approved", draft.getSubmittedAt(), now);
log.info("Draft approved: id={} dict={} bk={} op={} reviewer={}",
draft.getId(), def.getName(), draft.getBusinessKey(),
draft.getOperation(), reviewerId);
@@ -240,11 +278,14 @@ public class DraftService {
"Maker '" + reviewerId + "' не может reject свой draft.");
}
OffsetDateTime now = OffsetDateTime.now();
draft.setStatus(DraftStatus.REJECTED);
draft.setReviewerId(reviewerId);
draft.setReviewedAt(OffsetDateTime.now());
draft.setReviewedAt(now);
draft.setReviewComment(reviewComment);
RecordDraft saved = draftRepo.saveAndFlush(draft);
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
log.info("Draft rejected: id={} reviewer={} reason='{}'",
draftId, reviewerId, reviewComment);
return saved;
@@ -272,13 +313,56 @@ public class DraftService {
+ ", maker: " + draft.getMakerId());
}
OffsetDateTime now = OffsetDateTime.now();
draft.setStatus(DraftStatus.WITHDRAWN);
draft.setReviewedAt(OffsetDateTime.now());
draft.setReviewedAt(now);
RecordDraft saved = draftRepo.saveAndFlush(draft);
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
recordDecision(def.getName(), draft.getOperation(), "withdrawn", draft.getSubmittedAt(), now);
log.info("Draft withdrawn by maker: id={} maker={}", draftId, userId);
return saved;
}
// === Phase 4 metrics helpers ===
/**
* Counter: nsi_draft_submit_total{dictionary, operation, outcome}.
* Outcome: ok / already_pending / error. Помогает алертить аномальный
* процент already_pending (concurrent edits) или error (schema bugs).
*/
private void countSubmit(String dictName, DraftOperation op, String outcome) {
meterRegistry.ifPresent(reg ->
reg.counter("nsi_draft_submit_total",
Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome))
.increment());
}
/**
* Counter: nsi_draft_decision_total{dictionary, operation, outcome=approved|rejected|withdrawn}.
* Timer: nsi_draft_review_duration_seconds — distribution time-to-decide.
* Outcome=withdrawn ≠ review (maker сам отозвал), но включаем — нужно для
* full lifecycle visibility и сравнения throughput.
*/
private void recordDecision(
String dictName, DraftOperation op, String outcome,
OffsetDateTime submittedAt, OffsetDateTime decidedAt) {
meterRegistry.ifPresent(reg -> {
Tags tags = Tags.of("dictionary", dictName, "operation", op.name(), "outcome", outcome);
reg.counter("nsi_draft_decision_total", tags).increment();
if (submittedAt != null && decidedAt != null) {
Duration d = Duration.between(submittedAt.toInstant(), decidedAt.toInstant());
if (!d.isNegative()) {
Timer t = Timer.builder("nsi_draft_review_duration_seconds")
.description("Approval workflow: время от submit до decision")
.tags(tags)
.publishPercentileHistogram()
.register(reg);
t.record(d);
}
}
});
}
/** Drafts автора: tracking "что я отправил" в admin UI. */
@Transactional(readOnly = true)
public Page<RecordDraft> listByMaker(String makerId, Pageable pageable) {
@@ -361,6 +361,13 @@ class DraftServiceTest {
.toList();
}
@Override
public long countPending() {
return store.values().stream()
.filter(d -> d.getStatus() == DraftStatus.PENDING)
.count();
}
@Override
public org.springframework.data.domain.Page<RecordDraft> findByMaker(
String makerId, org.springframework.data.domain.Pageable pageable) {