ProjectRequirement.java

package com.taxonomy.portfolio.model;

import com.taxonomy.portfolio.model.PortfolioTypes.Criticality;
import com.taxonomy.portfolio.model.PortfolioTypes.RequirementStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.RequirementType;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinColumns;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;

import java.time.Instant;
import java.util.Objects;

/** Stable requirement identity within one exact project tenant. Text changes create versions. */
@Entity
@Table(name = "project_requirement", indexes = {
        @Index(name = "idx_req_project", columnList = "project_id"),
        @Index(name = "idx_req_status", columnList = "scope_key,project_id,status"),
        @Index(name = "idx_req_scope", columnList = "scope_key")
}, uniqueConstraints = {
        @UniqueConstraint(name = "uq_req_project_key",
                columnNames = {"scope_key", "project_id", "requirement_key"}),
        @UniqueConstraint(name = "uq_req_id_scope", columnNames = {"id", "scope_key"}),
        @UniqueConstraint(name = "uq_req_id_proj_scope",
                columnNames = {"id", "project_id", "scope_key"})
})
public class ProjectRequirement {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "scope_key", nullable = false,
            length = PortfolioTenantIdentity.MAX_SCOPE_KEY_LENGTH)
    private String scopeKey;

    /** Read-only scalar parent identity for scoped queries without initializing the association. */
    @Column(name = "project_id", nullable = false, insertable = false, updatable = false)
    private Long projectId;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "project_id", nullable = false)
    private ArchitectureProject project;

    @Column(name = "requirement_key", nullable = false, length = 64)
    private String requirementKey;

    @Column(nullable = false, length = 240)
    private String title;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private RequirementStatus status = RequirementStatus.DRAFT;

    @Column(nullable = false)
    private int priority = 50;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private Criticality criticality = Criticality.MEDIUM;

    @Enumerated(EnumType.STRING)
    @Column(name = "requirement_type", nullable = false, length = 32)
    private RequirementType requirementType = RequirementType.FUNCTIONAL;

    @Enumerated(EnumType.STRING)
    @Column(name = "review_status", nullable = false, length = 32)
    private ReviewStatus reviewStatus = ReviewStatus.PROPOSED;

    @Column(name = "owner_username", nullable = false, length = 160)
    private String ownerUsername;

    /** Points at the immutable current text version without creating a circular writable FK. */
    @Column(name = "current_version_id")
    private Long currentVersionId;

    /**
     * Read-only tenant- and requirement-bound association used by portfolio list
     * queries to fetch the current immutable version in the same SQL statement.
     * The requirement's own ID participates in the join, so a version from a
     * sibling requirement in the same tenant cannot be selected.
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumns({
            @JoinColumn(name = "current_version_id", referencedColumnName = "id",
                    insertable = false, updatable = false),
            @JoinColumn(name = "id", referencedColumnName = "requirement_id",
                    insertable = false, updatable = false),
            @JoinColumn(name = "scope_key", referencedColumnName = "scope_key",
                    insertable = false, updatable = false)
    })
    private ProjectRequirementVersion currentVersion;

    /** Points at the immutable current analysis snapshot. */
    @Column(name = "current_snapshot_id", length = 36)
    private String currentAnalysisSnapshotId;

    /**
     * Read-only exact-tenant association. The four-column identity prevents a
     * snapshot from a sibling requirement, project or branch being selected.
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumns({
            @JoinColumn(name = "current_snapshot_id", referencedColumnName = "id",
                    insertable = false, updatable = false),
            @JoinColumn(name = "id", referencedColumnName = "requirement_id",
                    insertable = false, updatable = false),
            @JoinColumn(name = "project_id", referencedColumnName = "project_id",
                    insertable = false, updatable = false),
            @JoinColumn(name = "scope_key", referencedColumnName = "scope_key",
                    insertable = false, updatable = false)
    })
    private RequirementAnalysisSnapshot currentAnalysisSnapshot;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    @Version
    @Column(name = "row_version", nullable = false)
    private long rowVersion;

    protected ProjectRequirement() {
    }

    public ProjectRequirement(ArchitectureProject project,
                              String requirementKey,
                              String title,
                              RequirementStatus status,
                              int priority,
                              Criticality criticality,
                              RequirementType requirementType,
                              ReviewStatus reviewStatus,
                              String ownerUsername,
                              Instant now) {
        this.project = project;
        this.requirementKey = requirementKey;
        this.title = title;
        this.status = status != null ? status : RequirementStatus.DRAFT;
        this.priority = priority;
        this.criticality = criticality != null ? criticality : Criticality.MEDIUM;
        this.requirementType = requirementType != null ? requirementType : RequirementType.FUNCTIONAL;
        this.reviewStatus = reviewStatus != null ? reviewStatus : ReviewStatus.PROPOSED;
        this.ownerUsername = ownerUsername;
        this.createdAt = now;
        this.updatedAt = now;
        synchronizeTenantAuthority(false);
    }

    public void updateMetadata(String title,
                               RequirementStatus status,
                               Integer priority,
                               Criticality criticality,
                               RequirementType requirementType,
                               ReviewStatus reviewStatus,
                               String ownerUsername,
                               Instant now) {
        if (title != null) this.title = title;
        if (status != null) this.status = status;
        if (priority != null) this.priority = priority;
        if (criticality != null) this.criticality = criticality;
        if (requirementType != null) this.requirementType = requirementType;
        if (reviewStatus != null) this.reviewStatus = reviewStatus;
        if (ownerUsername != null) this.ownerUsername = ownerUsername;
        this.updatedAt = now;
    }

    public void pointToVersion(Long versionId, Instant now) {
        this.currentVersionId = versionId;
        this.currentVersion = null;
        this.updatedAt = now;
    }

    public void pointToAnalysis(String snapshotId, Instant now) {
        this.currentAnalysisSnapshotId = snapshotId;
        this.currentAnalysisSnapshot = null;
        this.updatedAt = now;
    }

    @PrePersist
    @PreUpdate
    private void synchronizeTenantAuthority() {
        synchronizeTenantAuthority(true);
    }

    private void synchronizeTenantAuthority(boolean requirePersistentParent) {
        if (project == null || project.getScopeKey() == null
                || project.getScopeKey().isBlank()) {
            throw new IllegalArgumentException(
                    "Requirement project must expose an exact tenant scope");
        }
        String projectScope = project.getScopeKey().strip();
        if (scopeKey != null && !scopeKey.isBlank()
                && !projectScope.equals(scopeKey.strip())) {
            throw new IllegalArgumentException(
                    "Requirement tenant scope does not match its project");
        }
        scopeKey = projectScope;

        Long persistentProjectId = project.getId();
        if (persistentProjectId == null) {
            if (requirePersistentParent) {
                throw new IllegalArgumentException(
                        "Requirement project must be persisted before the requirement");
            }
            return;
        }
        if (projectId != null && !Objects.equals(projectId, persistentProjectId)) {
            throw new IllegalArgumentException(
                    "Requirement project ID does not match its association");
        }
        projectId = persistentProjectId;
    }

    public Long getId() { return id; }
    public String getScopeKey() { return scopeKey; }
    public Long getProjectId() { return projectId; }
    public ArchitectureProject getProject() { return project; }
    public String getRequirementKey() { return requirementKey; }
    public String getTitle() { return title; }
    public RequirementStatus getStatus() { return status; }
    public int getPriority() { return priority; }
    public Criticality getCriticality() { return criticality; }
    public RequirementType getRequirementType() { return requirementType; }
    public ReviewStatus getReviewStatus() { return reviewStatus; }
    public String getOwnerUsername() { return ownerUsername; }
    public Long getCurrentVersionId() { return currentVersionId; }
    public ProjectRequirementVersion getCurrentVersion() { return currentVersion; }
    public String getCurrentAnalysisSnapshotId() { return currentAnalysisSnapshotId; }
    public RequirementAnalysisSnapshot getCurrentAnalysisSnapshot() {
        return currentAnalysisSnapshot;
    }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
    public long getRowVersion() { return rowVersion; }
}