ProposalReviewStateStore.java

package com.taxonomy.relations.service;

import com.taxonomy.model.ProposalStatus;
import com.taxonomy.model.RelationType;
import com.taxonomy.relations.model.RelationProposal;
import com.taxonomy.relations.repository.RelationProposalRepository;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.RepositoryScope;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
 * Reads and advances proposal bookkeeping in short transactions around an
 * independently authoritative Git command.
 */
@Service
public class ProposalReviewStateStore {

    private final RelationProposalRepository proposalRepository;

    public ProposalReviewStateStore(
            RelationProposalRepository proposalRepository) {
        this.proposalRepository = Objects.requireNonNull(
                proposalRepository, "proposalRepository");
    }

    @Transactional(readOnly = true)
    public ProposalSnapshot require(
            Long proposalId,
            RepositoryContext context) {
        RepositoryContext tenant = requireWritableContext(context);
        RelationProposal proposal = find(proposalId, tenant, false);
        return new ProposalSnapshot(
                proposal.getId(),
                proposal.getSourceNode().getCode(),
                proposal.getTargetNode().getCode(),
                proposal.getRelationType(),
                proposal.getStatus(),
                proposal.getConfidence(),
                proposal.getRationale(),
                proposal.getProvenance());
    }

    @Transactional
    public ProposalStatus transition(
            Long proposalId,
            RepositoryContext context,
            ProposalStatus expected,
            ProposalStatus target) {
        RepositoryContext tenant = requireWritableContext(context);
        RelationProposal proposal = find(proposalId, tenant, true);
        if (proposal.getStatus() != expected) {
            throw new ProposalStateConflictException(
                    "Proposal " + proposalId + " is " + proposal.getStatus()
                            + ", expected " + expected);
        }
        proposal.setStatus(target);
        proposal.setReviewedAt(target == ProposalStatus.PENDING
                ? null : Instant.now());
        proposalRepository.save(proposal);
        return target;
    }

    private RelationProposal find(
            Long proposalId,
            RepositoryContext context,
            boolean forUpdate) {
        if (proposalId == null) {
            throw new IllegalArgumentException("proposalId must not be null");
        }
        return (forUpdate
                ? proposalRepository.findByIdInRepositoryWorkspaceForUpdate(
                        context.repositoryId(),
                        proposalId,
                        context.workspaceId())
                : proposalRepository.findByIdInRepositoryWorkspace(
                        context.repositoryId(),
                        proposalId,
                        context.workspaceId()))
                .orElseThrow(() -> new IllegalArgumentException(
                        "Proposal not found in active repository/workspace: "
                                + proposalId));
    }

    static RepositoryContext requireWritableContext(
            RepositoryContext context) {
        if (context == null) {
            throw new IllegalArgumentException(
                    "RepositoryContext must not be null");
        }
        if (context.workspaceId() == null
                && context.scope() != RepositoryScope.CENTRAL_WRITE
                && context.scope() != RepositoryScope.FORK) {
            throw new IllegalArgumentException(
                    "Repository proposal review requires CENTRAL_WRITE or FORK scope");
        }
        if (context.workspaceId() != null
                && context.scope() != RepositoryScope.WORKSPACE) {
            throw new IllegalArgumentException(
                    "Workspace proposal review requires WORKSPACE scope");
        }
        return context;
    }

    public record ProposalSnapshot(
            Long id,
            String sourceCode,
            String targetCode,
            RelationType relationType,
            ProposalStatus status,
            double confidence,
            String rationale,
            String provenance) {
        public ProposalSnapshot {
            id = Objects.requireNonNull(id, "id");
            sourceCode = Objects.requireNonNull(sourceCode, "sourceCode");
            targetCode = Objects.requireNonNull(targetCode, "targetCode");
            relationType = Objects.requireNonNull(relationType, "relationType");
            status = Objects.requireNonNull(status, "status");
        }
    }

    public static final class ProposalStateConflictException
            extends IllegalStateException {
        public ProposalStateConflictException(String message) {
            super(message);
        }
    }
}