RelationReviewService.java

package com.taxonomy.relations.service;

import com.taxonomy.catalog.service.TaxonomyRelationService;
import com.taxonomy.dto.RelationProposalDto;
import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.ProposalStatus;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;

/** Handles human review of relation proposals in an explicit repository tenant. */
@Service
public class RelationReviewService {

    private static final Logger log = LoggerFactory.getLogger(RelationReviewService.class);

    private final RelationProposalRepository proposalRepository;
    private final TaxonomyRelationService relationService;
    private final RelationProposalService proposalService;

    public RelationReviewService(RelationProposalRepository proposalRepository,
                                 TaxonomyRelationService relationService,
                                 RelationProposalService proposalService) {
        this.proposalRepository = proposalRepository;
        this.relationService = relationService;
        this.proposalService = proposalService;
    }

    /** Accept a proposal and create its relation in the exact writable context. */
    @Transactional
    public TaxonomyRelationDto acceptProposal(
            Long proposalId, RepositoryContext context) {
        RepositoryContext tenant = requireWritableContext(context);
        RelationProposal proposal = findProposalInContext(proposalId, tenant);
        if (proposal.getStatus() != ProposalStatus.PENDING) {
            throw new IllegalStateException(
                    "Proposal " + proposalId + " is already " + proposal.getStatus());
        }

        TaxonomyRelationDto relation = relationService.createRelationInContext(
                proposal.getSourceNode().getCode(),
                proposal.getTargetNode().getCode(),
                proposal.getRelationType(),
                proposal.getRationale(),
                "proposal-pipeline",
                tenant);

        proposal.setStatus(ProposalStatus.ACCEPTED);
        proposal.setReviewedAt(Instant.now());
        proposalRepository.save(proposal);
        log.info("Accepted proposal {}: {} → {} [{}] "
                        + "(repository={}, workspace={})",
                proposalId,
                proposal.getSourceNode().getCode(),
                proposal.getTargetNode().getCode(),
                proposal.getRelationType(),
                tenant.repositoryId(),
                tenant.workspaceId());
        return relation;
    }

    /** Reject a proposal in the exact writable context. */
    @Transactional
    public RelationProposalDto rejectProposal(
            Long proposalId, RepositoryContext context) {
        RepositoryContext tenant = requireWritableContext(context);
        RelationProposal proposal = findProposalInContext(proposalId, tenant);
        if (proposal.getStatus() != ProposalStatus.PENDING) {
            throw new IllegalStateException(
                    "Proposal " + proposalId + " is already " + proposal.getStatus());
        }

        proposal.setStatus(ProposalStatus.REJECTED);
        proposal.setReviewedAt(Instant.now());
        proposalRepository.save(proposal);
        log.info("Rejected proposal {}: {} → {} [{}] "
                        + "(repository={}, workspace={})",
                proposalId,
                proposal.getSourceNode().getCode(),
                proposal.getTargetNode().getCode(),
                proposal.getRelationType(),
                tenant.repositoryId(),
                tenant.workspaceId());
        return proposalService.toDto(proposal);
    }

    /**
     * Revert a reviewed proposal in the exact writable context. If accepted,
     * only the relation in that same repository/workspace is removed.
     */
    @Transactional
    public RelationProposalDto revertProposal(
            Long proposalId, RepositoryContext context) {
        RepositoryContext tenant = requireWritableContext(context);
        RelationProposal proposal = findProposalInContext(proposalId, tenant);
        if (proposal.getStatus() == ProposalStatus.PENDING) {
            throw new IllegalStateException("Proposal " + proposalId + " is already PENDING");
        }

        ProposalStatus oldStatus = proposal.getStatus();
        if (oldStatus == ProposalStatus.ACCEPTED) {
            relationService.deleteRelationBySourceTargetTypeInContext(
                    proposal.getSourceNode().getCode(),
                    proposal.getTargetNode().getCode(),
                    proposal.getRelationType(),
                    tenant);
        }

        proposal.setStatus(ProposalStatus.PENDING);
        proposal.setReviewedAt(null);
        proposalRepository.save(proposal);
        log.info("Reverted proposal {} from {} to PENDING: {} → {} [{}] "
                        + "(repository={}, workspace={})",
                proposalId,
                oldStatus,
                proposal.getSourceNode().getCode(),
                proposal.getTargetNode().getCode(),
                proposal.getRelationType(),
                tenant.repositoryId(),
                tenant.workspaceId());
        return proposalService.toDto(proposal);
    }

    private RelationProposal findProposalInContext(
            Long proposalId, RepositoryContext context) {
        return proposalRepository.findByIdInRepositoryWorkspace(
                        context.repositoryId(),
                        proposalId,
                        context.workspaceId())
                .orElseThrow(() -> new IllegalArgumentException(
                        "Proposal not found in active repository/workspace: " + proposalId));
    }

    private 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) {
            throw new IllegalArgumentException(
                    "Central proposal review requires CENTRAL_WRITE scope");
        }
        if (context.workspaceId() != null
                && context.scope() != RepositoryScope.WORKSPACE
                && context.scope() != RepositoryScope.FORK) {
            throw new IllegalArgumentException(
                    "Workspace proposal review requires WORKSPACE or FORK scope");
        }
        return context;
    }
}