Merge branch 'feat/changelog-full-timeline' into 'main'
feat(api): full timeline в /changelog — workflow events + draft kinds See merge request 2-6/2-6-4/terravault/ordinis!116
This commit is contained in:
+21
@@ -76,6 +76,27 @@ public class AuditLogger {
|
||||
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(
|
||||
String eventType,
|
||||
String action,
|
||||
|
||||
+81
-7
@@ -45,8 +45,30 @@ import java.util.TreeSet;
|
||||
@Service
|
||||
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 DictionaryDefinitionService definitionService;
|
||||
@@ -79,12 +101,25 @@ public class ChangelogService {
|
||||
List<AuditLog> rows = auditRepo.findByDictionaryIdAndEventTypes(
|
||||
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());
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
AuditLog row = rows.get(i);
|
||||
boolean isCurrent = i == 0; // most recent — assumed current (DESC sort)
|
||||
String version = isCurrent ? d.getSchemaVersion() : null;
|
||||
boolean isCurrent = i == mostRecentVersioningIdx;
|
||||
// Version label: только для versioning events; older entries без backfill = null.
|
||||
String version = isCurrent
|
||||
? d.getSchemaVersion()
|
||||
: null;
|
||||
DiffSummary diff = computeDiff(row.getPayloadBefore(), row.getPayloadAfter());
|
||||
String kind = mapEventTypeToKind(row.getEventType(), row.getAction());
|
||||
entries.add(new ChangelogEntry(
|
||||
version,
|
||||
row.getEventTime(),
|
||||
@@ -92,12 +127,42 @@ public class ChangelogService {
|
||||
null, // headers (commit msg) not captured yet — see TODO
|
||||
diff,
|
||||
0, // recordsAffected — not tracked at audit level; future field
|
||||
isCurrent));
|
||||
isCurrent,
|
||||
kind));
|
||||
}
|
||||
|
||||
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 —
|
||||
* NULL before = creation, NULL after = (deletion, не используется сейчас).
|
||||
@@ -151,7 +216,15 @@ public class ChangelogService {
|
||||
|
||||
// === 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(
|
||||
String version,
|
||||
OffsetDateTime publishedAt,
|
||||
@@ -159,7 +232,8 @@ public class ChangelogService {
|
||||
String headers,
|
||||
DiffSummary diff,
|
||||
int recordsAffected,
|
||||
boolean isCurrent) {}
|
||||
boolean isCurrent,
|
||||
String kind) {}
|
||||
|
||||
/** Author identity. sub — JWT subject claim (Keycloak user id). */
|
||||
public record Publisher(String sub, String name) {}
|
||||
|
||||
+57
-4
@@ -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.events.EventEnvelope;
|
||||
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.DecisionRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
||||
@@ -62,16 +63,30 @@ public class SchemaDraftService {
|
||||
private final DictionaryDefinitionService definitionService;
|
||||
private final OutboxRecorder outboxRecorder;
|
||||
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(
|
||||
SchemaDraftRepository repository,
|
||||
DictionaryDefinitionService definitionService,
|
||||
OutboxRecorder outboxRecorder,
|
||||
ObjectMapper objectMapper) {
|
||||
this.repository = repository;
|
||||
this.definitionService = definitionService;
|
||||
this.outboxRecorder = outboxRecorder;
|
||||
this.objectMapper = objectMapper;
|
||||
this(repository, definitionService, outboxRecorder, objectMapper, null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -151,6 +166,11 @@ public class SchemaDraftService {
|
||||
DictionarySchemaDraft saved = repository.save(draft);
|
||||
emitEvent("SchemaDraftCreated", saved, def, Map.of(
|
||||
"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={}",
|
||||
saved.getId(), dictionaryName, saved.getMakerId());
|
||||
return saved;
|
||||
@@ -193,6 +213,11 @@ public class SchemaDraftService {
|
||||
emitEvent("SchemaDraftReviewRequested", saved, def, Map.of(
|
||||
"reviewers", req.reviewers() == null ? List.of() : req.reviewers(),
|
||||
"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={}",
|
||||
saved.getId(), def.getName(), req.reviewers());
|
||||
return saved;
|
||||
@@ -238,6 +263,18 @@ public class SchemaDraftService {
|
||||
"decision", req.decision().name().toLowerCase(),
|
||||
"reviewerId", reviewerId,
|
||||
"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={}",
|
||||
saved.getId(), def.getName(), req.decision(), reviewerId);
|
||||
return saved;
|
||||
@@ -295,6 +332,17 @@ public class SchemaDraftService {
|
||||
"newVersion", newVersion,
|
||||
"publishedAt", publishedAt.toString(),
|
||||
"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={}",
|
||||
saved.getId(), def.getName(), newVersion);
|
||||
return saved;
|
||||
@@ -316,6 +364,11 @@ public class SchemaDraftService {
|
||||
DictionarySchemaDraft saved = repository.save(draft);
|
||||
DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
|
||||
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={}",
|
||||
saved.getId(), def.getName(), saved.getMakerId());
|
||||
return saved;
|
||||
|
||||
Reference in New Issue
Block a user