feat(api): full timeline в /changelog — workflow events + draft kinds
This commit is contained in:
+21
@@ -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,
|
||||||
|
|||||||
+81
-7
@@ -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) {}
|
||||||
|
|||||||
+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.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user