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 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 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 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 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 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. + * + *

{@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;