WorkflowCombinedHistoryHttpAdapter.java

package org.hammer.audio.workflow.editor.http;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import org.hammer.audio.workflow.history.IndexedWorkflowCombinedHistorySearch;
import org.hammer.audio.workflow.history.WorkflowCombinedHistoryQuery;
import org.hammer.audio.workflow.history.WorkflowCombinedHistoryResult;
import org.hammer.audio.workflow.history.WorkflowHistoryTextQuery;
import org.hammer.audio.workflow.history.WorkflowSemanticHistoryFilter;
import org.hammer.audio.workflow.history.WorkflowSemanticProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/** HTTP boundary for one correctly composed generic and semantic workflow-history query. */
@RestController
@RequestMapping("/workflow/history/combined")
@ConditionalOnProperty(name = "workbench.persistence.mode", havingValue = "hibernate")
public final class WorkflowCombinedHistoryHttpAdapter {

  private static final int DEFAULT_LIMIT = 20;
  private final IndexedWorkflowCombinedHistorySearch search;

  /** Creates the combined history controller. */
  public WorkflowCombinedHistoryHttpAdapter(IndexedWorkflowCombinedHistorySearch search) {
    this.search = Objects.requireNonNull(search, "search");
  }

  /** Executes semantic candidate selection before generic ranking and the final result limit. */
  @PostMapping("/query")
  public List<CombinedHitResponse> query(@Valid @RequestBody CombinedSearchRequest request) {
    return search.searchCombined(request.toDomain()).stream()
        .map(CombinedHitResponse::from)
        .toList();
  }

  /**
   * Combined request with independently named generic and semantic filters.
   *
   * @param generic generic commit-history predicates and final result limit
   * @param semantic branch and workflow-domain predicates
   */
  public record CombinedSearchRequest(
      @NotNull @Valid GenericFilters generic, @NotNull @Valid SemanticFilters semantic) {

    public CombinedSearchRequest {
      // Bean validation owns the transport contract.
    }

    WorkflowCombinedHistoryQuery toDomain() {
      return new WorkflowCombinedHistoryQuery(generic.toDomain(), semantic.toDomain());
    }
  }

  /**
   * Generic commit-history filters.
   *
   * @param text full-text expression
   * @param authorEmail exact author email
   * @param pathText analyzed changed-path expression
   * @param from inclusive lower time bound
   * @param to inclusive upper time bound
   * @param limit final result limit, defaulting to 20
   */
  public record GenericFilters(
      String text,
      String authorEmail,
      String pathText,
      Instant from,
      Instant to,
      @Min(1) @Max(200) Integer limit) {

    public GenericFilters {
      // Domain construction normalizes optional strings and validates time bounds.
    }

    WorkflowHistoryTextQuery toDomain() {
      return new WorkflowHistoryTextQuery(
          text, authorEmail, pathText, from, to, limit == null ? DEFAULT_LIMIT : limit);
    }
  }

  /**
   * Workflow-semantic filters.
   *
   * @param branch required reachability boundary
   * @param workflowId exact workflow identifier
   * @param nodeId exact node identifier
   * @param nodeType exact node type
   * @param labelText workflow-name or node-label expression
   * @param propertyKey exact metadata key
   * @param propertyValue exact metadata value
   */
  public record SemanticFilters(
      @NotBlank String branch,
      String workflowId,
      String nodeId,
      String nodeType,
      String labelText,
      String propertyKey,
      String propertyValue) {

    public SemanticFilters {
      // Domain construction normalizes optional semantic values.
    }

    WorkflowSemanticHistoryFilter toDomain() {
      return new WorkflowSemanticHistoryFilter(
          branch, workflowId, nodeId, nodeType, labelText, propertyKey, propertyValue);
    }
  }

  /**
   * Combined response for one exact commit.
   *
   * @param commit generic commit metadata and changed-path evidence
   * @param semantics branch-specific workflow-domain evidence
   */
  public record CombinedHitResponse(CommitEvidence commit, SemanticEvidence semantics) {

    public CombinedHitResponse {
      Objects.requireNonNull(commit, "commit");
      Objects.requireNonNull(semantics, "semantics");
    }

    static CombinedHitResponse from(WorkflowCombinedHistoryResult result) {
      return new CombinedHitResponse(CommitEvidence.from(result), SemanticEvidence.from(result));
    }
  }

  /**
   * Generic exact-commit evidence.
   *
   * @param commitId exact Git commit identity
   * @param message short commit message
   * @param authorName author display name
   * @param authorEmail exact author email
   * @param timestamp commit timestamp
   * @param changedPaths first-parent changed paths
   */
  public record CommitEvidence(
      String commitId,
      String message,
      String authorName,
      String authorEmail,
      Instant timestamp,
      List<String> changedPaths) {

    public CommitEvidence {
      commitId = Objects.requireNonNull(commitId, "commitId");
      changedPaths = List.copyOf(Objects.requireNonNull(changedPaths, "changedPaths"));
    }

    static CommitEvidence from(WorkflowCombinedHistoryResult result) {
      return new CommitEvidence(
          result.commit().commitId().value(),
          result.commit().shortMessage(),
          result.commit().authorName(),
          result.commit().authorEmail(),
          result.commit().timestamp(),
          result.commit().changedPaths());
    }
  }

  /**
   * Branch-specific workflow-domain evidence.
   *
   * @param branch branch from which the commit is reachable
   * @param workflowId exact workflow identifier
   * @param workflowName workflow display name
   * @param nodeIds exact node identifiers
   * @param nodeTypes exact node types
   * @param nodeLabels node labels
   * @param properties correlated metadata entries
   */
  public record SemanticEvidence(
      String branch,
      String workflowId,
      String workflowName,
      List<String> nodeIds,
      List<String> nodeTypes,
      List<String> nodeLabels,
      List<WorkflowSemanticProperty> properties) {

    public SemanticEvidence {
      branch = Objects.requireNonNull(branch, "branch");
      workflowId = Objects.requireNonNull(workflowId, "workflowId");
      workflowName = Objects.requireNonNull(workflowName, "workflowName");
      nodeIds = List.copyOf(Objects.requireNonNull(nodeIds, "nodeIds"));
      nodeTypes = List.copyOf(Objects.requireNonNull(nodeTypes, "nodeTypes"));
      nodeLabels = List.copyOf(Objects.requireNonNull(nodeLabels, "nodeLabels"));
      properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
    }

    static SemanticEvidence from(WorkflowCombinedHistoryResult result) {
      return new SemanticEvidence(
          result.semantics().branch(),
          result.semantics().workflowId(),
          result.semantics().workflowName(),
          result.semantics().nodeIds(),
          result.semantics().nodeTypes(),
          result.semantics().nodeLabels(),
          result.semantics().properties());
    }
  }
}