diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java index 17c2162..709bece 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/audit/AuditLogger.java @@ -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_*). + * + *
{@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,
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java
index edda0af..b036996 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java
@@ -45,8 +45,30 @@ import java.util.TreeSet;
@Service
public class ChangelogService {
- private static final List {@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) {}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java
index d958c91..48cbffe 100644
--- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java
@@ -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;