feat(api): full timeline в /changelog — workflow events + draft kinds

This commit is contained in:
Александр Зимин
2026-05-12 00:14:09 +00:00
parent 50c75eacd3
commit 12975e0d83
3 changed files with 159 additions and 11 deletions
@@ -76,6 +76,27 @@ public class AuditLogger {
write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null); write("RECORD_CLOSED", "CLOSE", d, r, r.getData(), null);
} }
/**
* Schema workflow audit events (Phase 1 v2.2.0). Schema draft lifecycle
* пишется в audit_log дополнительно к outbox чтобы ChangelogService
* мог построить полный timeline (definition_* + schema_draft_*).
*
* <p>{@code before} = previous schema, {@code after} = proposed schema —
* полезно для diff render'а в History tab UI: «draft предлагал +altitude
* / ~operator» без необходимости отдельно fetch'ить proposed_schema.
*
* @param eventType e.g. {@code SCHEMA_DRAFT_CREATED}, {@code SCHEMA_DRAFT_PUBLISHED}
* @param action e.g. {@code DRAFT_CREATE}, {@code DRAFT_PUBLISH}
* @param d живой dict (для scope + id)
* @param before previous schema (live HEAD на момент event'а)
* @param after proposed schema (для published — final live schema)
*/
public void schemaDraftEvent(
String eventType, String action, DictionaryDefinition d,
JsonNode before, JsonNode after) {
write(eventType, action, d, null, before, after);
}
private void write( private void write(
String eventType, String eventType,
String action, String action,
@@ -45,8 +45,30 @@ import java.util.TreeSet;
@Service @Service
public class ChangelogService { public class ChangelogService {
private static final List<String> DEFINITION_EVENT_TYPES = /**
List.of("DEFINITION_CREATED", "DEFINITION_UPDATED"); * Full timeline events — definition-level CRUD + schema workflow drafts.
* Раньше включал только DEFINITION_* — пользователь жаловался «полный
* timeline pending», т.к. workflow draft transitions (created / review /
* decided / published / withdrawn) не появлялись в History tab.
*/
private static final List<String> DEFINITION_EVENT_TYPES = List.of(
"DEFINITION_CREATED",
"DEFINITION_UPDATED",
"SCHEMA_DRAFT_CREATED",
"SCHEMA_DRAFT_REVIEW_REQUESTED",
"SCHEMA_DRAFT_DECIDED",
"SCHEMA_DRAFT_PUBLISHED",
"SCHEMA_DRAFT_WITHDRAWN");
/**
* Event types которые меняют live schema → формируют version row в timeline.
* Остальные (draft draft_created, review, decided=non-publish, withdrawn)
* — promotional events без version bump'а.
*/
private static final Set<String> VERSIONING_EVENTS = Set.of(
"DEFINITION_CREATED",
"DEFINITION_UPDATED",
"SCHEMA_DRAFT_PUBLISHED");
private final AuditLogRepository auditRepo; private final AuditLogRepository auditRepo;
private final DictionaryDefinitionService definitionService; private final DictionaryDefinitionService definitionService;
@@ -79,12 +101,25 @@ public class ChangelogService {
List<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes( List<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes(
d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit)); d.getId(), DEFINITION_EVENT_TYPES, PageRequest.of(0, safeLimit));
// Find the most recent versioning event — оно current HEAD.
int mostRecentVersioningIdx = -1;
for (int i = 0; i < rows.size(); i++) {
if (VERSIONING_EVENTS.contains(rows.get(i).getEventType())) {
mostRecentVersioningIdx = i;
break;
}
}
List<ChangelogEntry> entries = new ArrayList<>(rows.size()); List<ChangelogEntry> entries = new ArrayList<>(rows.size());
for (int i = 0; i < rows.size(); i++) { for (int i = 0; i < rows.size(); i++) {
AuditLog row = rows.get(i); AuditLog row = rows.get(i);
boolean isCurrent = i == 0; // most recent — assumed current (DESC sort) boolean isCurrent = i == mostRecentVersioningIdx;
String version = isCurrent ? d.getSchemaVersion() : null; // Version label: только для versioning events; older entries без backfill = null.
String version = isCurrent
? d.getSchemaVersion()
: null;
DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter()); DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
String kind = mapEventTypeToKind(row.getEventType(), row.getAction());
entries.add(new ChangelogEntry( entries.add(new ChangelogEntry(
version, version,
row.getEventTime(), row.getEventTime(),
@@ -92,12 +127,42 @@ public class ChangelogService {
null, // headers (commit msg) not captured yet — see TODO null, // headers (commit msg) not captured yet — see TODO
diff, diff,
0, // recordsAffected — not tracked at audit level; future field 0, // recordsAffected — not tracked at audit level; future field
isCurrent)); isCurrent,
kind));
} }
return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries); return new ChangelogResponse(d.getName(), d.getSchemaVersion(), entries);
} }
/**
* Map raw event_type + action в frontend-friendly kind label. Используется
* UI чтобы рендерить разные icons / colors / labels:
* - {@code definition_create} / {@code definition_update} — direct schema change
* - {@code draft_create} / {@code draft_review} — pre-publish phases
* - {@code draft_approve} / {@code draft_reject} / {@code draft_changes_requested}
* - {@code draft_publish} — version-bumping publication
* - {@code draft_withdraw} — abandoned draft
* - {@code other} — fallback для unknown
*/
private static String mapEventTypeToKind(String eventType, String action) {
if (eventType == null) return "other";
return switch (eventType) {
case "DEFINITION_CREATED" -> "definition_create";
case "DEFINITION_UPDATED" -> "definition_update";
case "SCHEMA_DRAFT_CREATED" -> "draft_create";
case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
case "DRAFT_APPROVE" -> "draft_approve";
case "DRAFT_REJECT" -> "draft_reject";
case "DRAFT_CHANGES_REQUESTED" -> "draft_changes_requested";
default -> "draft_decided";
};
case "SCHEMA_DRAFT_PUBLISHED" -> "draft_publish";
case "SCHEMA_DRAFT_WITHDRAWN" -> "draft_withdraw";
default -> "other";
};
}
/** /**
* Compute property-key diff between two schema JsonNodes. Both nullable — * Compute property-key diff between two schema JsonNodes. Both nullable —
* NULL before = creation, NULL after = (deletion, не используется сейчас). * NULL before = creation, NULL after = (deletion, не используется сейчас).
@@ -151,7 +216,15 @@ public class ChangelogService {
// === DTOs === // === DTOs ===
/** Per-version entry. version=null для старых entries без backfill. */ /**
* Per-event timeline entry. {@code version} non-null только для
* versioning events ({@code definition_create} / {@code definition_update} /
* {@code draft_publish}); promotional draft events возвращаются с version=null.
*
* <p>{@code kind} — frontend-friendly event type label (см.
* {@link ChangelogService#mapEventTypeToKind}). Используется UI для
* выбора icon/color/label.
*/
public record ChangelogEntry( public record ChangelogEntry(
String version, String version,
OffsetDateTime publishedAt, OffsetDateTime publishedAt,
@@ -159,7 +232,8 @@ public class ChangelogService {
String headers, String headers,
DiffSummary diff, DiffSummary diff,
int recordsAffected, int recordsAffected,
boolean isCurrent) {} boolean isCurrent,
String kind) {}
/** Author identity. sub — JWT subject claim (Keycloak user id). */ /** Author identity. sub — JWT subject claim (Keycloak user id). */
public record Publisher(String sub, String name) {} public record Publisher(String sub, String name) {}
@@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftReposito
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus; import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
import cloud.nstart.terravault.ordinis.events.EventEnvelope; import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
@@ -62,16 +63,30 @@ public class SchemaDraftService {
private final DictionaryDefinitionService definitionService; private final DictionaryDefinitionService definitionService;
private final OutboxRecorder outboxRecorder; private final OutboxRecorder outboxRecorder;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/** Optional — NULL в test ctor'е (без audit_log writes). */
private final AuditLogger auditLogger;
@org.springframework.beans.factory.annotation.Autowired
public SchemaDraftService(
SchemaDraftRepository repository,
DictionaryDefinitionService definitionService,
OutboxRecorder outboxRecorder,
ObjectMapper objectMapper,
AuditLogger auditLogger) {
this.repository = repository;
this.definitionService = definitionService;
this.outboxRecorder = outboxRecorder;
this.objectMapper = objectMapper;
this.auditLogger = auditLogger;
}
/** Test ctor — без audit_log. */
public SchemaDraftService( public SchemaDraftService(
SchemaDraftRepository repository, SchemaDraftRepository repository,
DictionaryDefinitionService definitionService, DictionaryDefinitionService definitionService,
OutboxRecorder outboxRecorder, OutboxRecorder outboxRecorder,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.repository = repository; this(repository, definitionService, outboxRecorder, objectMapper, null);
this.definitionService = definitionService;
this.outboxRecorder = outboxRecorder;
this.objectMapper = objectMapper;
} }
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -151,6 +166,11 @@ public class SchemaDraftService {
DictionarySchemaDraft saved = repository.save(draft); DictionarySchemaDraft saved = repository.save(draft);
emitEvent("SchemaDraftCreated", saved, def, Map.of( emitEvent("SchemaDraftCreated", saved, def, Map.of(
"fromVersion", req.fromVersion())); "fromVersion", req.fromVersion()));
if (auditLogger != null) {
auditLogger.schemaDraftEvent(
"SCHEMA_DRAFT_CREATED", "DRAFT_CREATE", def,
def.getSchemaJson(), saved.getProposedSchema());
}
log.info("schema_draft_created id={} dict={} maker={}", log.info("schema_draft_created id={} dict={} maker={}",
saved.getId(), dictionaryName, saved.getMakerId()); saved.getId(), dictionaryName, saved.getMakerId());
return saved; return saved;
@@ -193,6 +213,11 @@ public class SchemaDraftService {
emitEvent("SchemaDraftReviewRequested", saved, def, Map.of( emitEvent("SchemaDraftReviewRequested", saved, def, Map.of(
"reviewers", req.reviewers() == null ? List.of() : req.reviewers(), "reviewers", req.reviewers() == null ? List.of() : req.reviewers(),
"note", req.note() == null ? "" : req.note())); "note", req.note() == null ? "" : req.note()));
if (auditLogger != null) {
auditLogger.schemaDraftEvent(
"SCHEMA_DRAFT_REVIEW_REQUESTED", "DRAFT_REVIEW", def,
def.getSchemaJson(), saved.getProposedSchema());
}
log.info("schema_draft_review_pending id={} dict={} reviewers={}", log.info("schema_draft_review_pending id={} dict={} reviewers={}",
saved.getId(), def.getName(), req.reviewers()); saved.getId(), def.getName(), req.reviewers());
return saved; return saved;
@@ -238,6 +263,18 @@ public class SchemaDraftService {
"decision", req.decision().name().toLowerCase(), "decision", req.decision().name().toLowerCase(),
"reviewerId", reviewerId, "reviewerId", reviewerId,
"comment", req.comment() == null ? "" : req.comment())); "comment", req.comment() == null ? "" : req.comment()));
if (auditLogger != null) {
// Action разделён по verdict чтобы timeline UI мог красить approve
// зелёным, reject красным, changes_requested желтым.
String action = switch (req.decision()) {
case APPROVE -> "DRAFT_APPROVE";
case REQUEST_CHANGES -> "DRAFT_CHANGES_REQUESTED";
case REJECT -> "DRAFT_REJECT";
};
auditLogger.schemaDraftEvent(
"SCHEMA_DRAFT_DECIDED", action, def,
def.getSchemaJson(), saved.getProposedSchema());
}
log.info("schema_draft_decided id={} dict={} decision={} reviewer={}", log.info("schema_draft_decided id={} dict={} decision={} reviewer={}",
saved.getId(), def.getName(), req.decision(), reviewerId); saved.getId(), def.getName(), req.decision(), reviewerId);
return saved; return saved;
@@ -295,6 +332,17 @@ public class SchemaDraftService {
"newVersion", newVersion, "newVersion", newVersion,
"publishedAt", publishedAt.toString(), "publishedAt", publishedAt.toString(),
"publishNote", req.publishNote() == null ? "" : req.publishNote())); "publishNote", req.publishNote() == null ? "" : req.publishNote()));
if (auditLogger != null) {
// Schema-level write идёт через draft path — DEFINITION_UPDATED тоже
// имеет смысл (мы только что обновили def.schemaJson + version), но
// чтобы не дублировать запись пишем единственное SCHEMA_DRAFT_PUBLISHED
// — оно self-describes как version-bump (before/after schemas есть).
// ChangelogService рендерит как "v1.5.0 (опубликован из draft'а)".
auditLogger.schemaDraftEvent(
"SCHEMA_DRAFT_PUBLISHED", "DRAFT_PUBLISH", def,
draft.getBranchedFromVersion() == null ? null : def.getSchemaJson(),
draft.getProposedSchema());
}
log.info("schema_draft_published id={} dict={} newVersion={}", log.info("schema_draft_published id={} dict={} newVersion={}",
saved.getId(), def.getName(), newVersion); saved.getId(), def.getName(), newVersion);
return saved; return saved;
@@ -316,6 +364,11 @@ public class SchemaDraftService {
DictionarySchemaDraft saved = repository.save(draft); DictionarySchemaDraft saved = repository.save(draft);
DictionaryDefinition def = definitionService.findById(saved.getDictionaryId()); DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
emitEvent("SchemaDraftWithdrawn", saved, def, Map.of()); emitEvent("SchemaDraftWithdrawn", saved, def, Map.of());
if (auditLogger != null) {
auditLogger.schemaDraftEvent(
"SCHEMA_DRAFT_WITHDRAWN", "DRAFT_WITHDRAW", def,
def.getSchemaJson(), saved.getProposedSchema());
}
log.info("schema_draft_withdrawn id={} dict={} maker={}", log.info("schema_draft_withdrawn id={} dict={} maker={}",
saved.getId(), def.getName(), saved.getMakerId()); saved.getId(), def.getName(), saved.getMakerId());
return saved; return saved;