TaxonomyRelationService.java

package com.taxonomy.catalog.service;

import com.taxonomy.dto.TaxonomyRelationDto;
import com.taxonomy.model.RelationType;
import com.taxonomy.catalog.model.TaxonomyNode;
import com.taxonomy.catalog.model.TaxonomyRelation;
import com.taxonomy.catalog.repository.TaxonomyNodeRepository;
import com.taxonomy.catalog.repository.TaxonomyRelationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
public class TaxonomyRelationService {

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

    private final TaxonomyRelationRepository relationRepository;
    private final TaxonomyNodeRepository nodeRepository;

    public TaxonomyRelationService(TaxonomyRelationRepository relationRepository,
                                   TaxonomyNodeRepository nodeRepository) {
        this.relationRepository = relationRepository;
        this.nodeRepository = nodeRepository;
    }

    // ── Workspace-scoped read methods ───────────────────────────────

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getRelationsForNode(String code, @Nullable String workspaceId) {
        List<TaxonomyRelation> relations;
        if (workspaceId != null) {
            relations = relationRepository.findByWorkspaceAndNodeCode(workspaceId, code);
        } else {
            relations = relationRepository.findBySourceNodeCodeOrTargetNodeCode(code, code);
        }
        List<TaxonomyRelationDto> dtos = new ArrayList<>();
        for (TaxonomyRelation relation : relations) {
            dtos.add(toDto(relation));
        }
        return dtos;
    }

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getRelationsByType(RelationType type, @Nullable String workspaceId) {
        List<TaxonomyRelation> relations;
        if (workspaceId != null) {
            relations = relationRepository.findByWorkspaceAndRelationType(workspaceId, type);
        } else {
            relations = relationRepository.findByRelationType(type);
        }
        List<TaxonomyRelationDto> dtos = new ArrayList<>();
        for (TaxonomyRelation relation : relations) {
            dtos.add(toDto(relation));
        }
        return dtos;
    }

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getAllRelations(@Nullable String workspaceId) {
        List<TaxonomyRelation> relations;
        if (workspaceId != null) {
            relations = relationRepository.findByWorkspaceIdIsNullOrWorkspaceId(workspaceId);
        } else {
            relations = relationRepository.findAll();
        }
        List<TaxonomyRelationDto> dtos = new ArrayList<>();
        for (TaxonomyRelation relation : relations) {
            dtos.add(toDto(relation));
        }
        return dtos;
    }

    // ── Legacy delegating read methods (backward-compatible) ─────────

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getRelationsForNode(String code) {
        return getRelationsForNode(code, null);
    }

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getRelationsByType(RelationType type) {
        return getRelationsByType(type, null);
    }

    @Transactional(readOnly = true)
    public List<TaxonomyRelationDto> getAllRelations() {
        return getAllRelations(null);
    }

    // ── Workspace-scoped write methods ──────────────────────────────

    @Transactional
    public TaxonomyRelationDto createRelation(String sourceCode, String targetCode,
                                              RelationType type, String description,
                                              String provenance,
                                              @Nullable String workspaceId,
                                              @Nullable String ownerUsername) {
        TaxonomyNode source = nodeRepository.findByCode(sourceCode)
                .orElseThrow(() -> new IllegalArgumentException("Source node not found: " + sourceCode));
        TaxonomyNode target = nodeRepository.findByCode(targetCode)
                .orElseThrow(() -> new IllegalArgumentException("Target node not found: " + targetCode));

        // Programmatic duplicate check (covers NULL workspace_id in unique constraint)
        List<TaxonomyRelation> existing;
        if (workspaceId != null) {
            existing = relationRepository.findByWorkspaceAndSourceTargetType(
                    workspaceId, sourceCode, targetCode, type);
        } else {
            existing = relationRepository.findBySourceNodeCodeAndTargetNodeCodeAndRelationType(
                    sourceCode, targetCode, type);
            // Filter to only those with null workspace for exact match
            existing = existing.stream()
                    .filter(r -> r.getWorkspaceId() == null)
                    .toList();
        }
        if (!existing.isEmpty()) {
            throw new IllegalArgumentException(String.format(
                    "Relation already exists: %s --[%s]--> %s (workspace=%s)",
                    sourceCode, type, targetCode, workspaceId));
        }

        TaxonomyRelation relation = new TaxonomyRelation();
        relation.setSourceNode(source);
        relation.setTargetNode(target);
        relation.setRelationType(type);
        relation.setDescription(description);
        relation.setProvenance(provenance);
        relation.setWorkspaceId(workspaceId);
        relation.setOwnerUsername(ownerUsername);

        TaxonomyRelation saved = relationRepository.save(relation);
        log.info("Created relation: {} --[{}]--> {} (workspace={})", sourceCode, type, targetCode, workspaceId);
        return toDto(saved);
    }

    /**
     * Legacy overload — delegates to workspace-aware method with null workspace.
     */
    @Transactional
    public TaxonomyRelationDto createRelation(String sourceCode, String targetCode,
                                              RelationType type, String description,
                                              String provenance) {
        return createRelation(sourceCode, targetCode, type, description, provenance, null, null);
    }

    /**
     * Delete a relation by ID, with optional workspace ownership check.
     *
     * <p>If {@code workspaceId} is non-null, the relation must either belong to
     * the given workspace or be a shared (null workspace) relation. If it belongs
     * to a different workspace, an {@link IllegalArgumentException} is thrown.
     */
    @Transactional
    public void deleteRelation(Long id, @Nullable String workspaceId) {
        if (workspaceId != null) {
            TaxonomyRelation relation = relationRepository.findById(id).orElse(null);
            if (relation != null && relation.getWorkspaceId() != null
                    && !workspaceId.equals(relation.getWorkspaceId())) {
                throw new IllegalArgumentException(
                        "Relation " + id + " belongs to workspace '" + relation.getWorkspaceId()
                                + "', not '" + workspaceId + "'");
            }
        }
        relationRepository.deleteById(id);
        log.info("Deleted relation with id: {} (workspace={})", id, workspaceId);
    }

    /** Legacy overload — deletes without workspace ownership check. */
    @Transactional
    public void deleteRelation(Long id) {
        deleteRelation(id, null);
    }

    /**
     * Delete all relations matching a specific source, target, and type combination.
     * Used by the proposal revert mechanism to remove accepted relations.
     */
    @Transactional
    public void deleteRelationBySourceTargetType(String sourceCode, String targetCode,
                                                  RelationType type) {
        List<TaxonomyRelation> matches = relationRepository
                .findBySourceNodeCodeAndTargetNodeCodeAndRelationType(sourceCode, targetCode, type);
        if (!matches.isEmpty()) {
            relationRepository.deleteAll(matches);
            log.info("Deleted {} relation(s): {} --[{}]--> {}", matches.size(), sourceCode, type, targetCode);
        }
    }

    public TaxonomyRelationDto toDto(TaxonomyRelation relation) {
        TaxonomyRelationDto dto = new TaxonomyRelationDto();
        dto.setId(relation.getId());
        dto.setSourceCode(relation.getSourceNode().getCode());
        dto.setSourceName(relation.getSourceNode().getNameEn());
        dto.setTargetCode(relation.getTargetNode().getCode());
        dto.setTargetName(relation.getTargetNode().getNameEn());
        dto.setRelationType(relation.getRelationType().name());
        dto.setDescription(relation.getDescription());
        dto.setProvenance(relation.getProvenance());
        dto.setWeight(relation.getWeight());
        dto.setBidirectional(relation.isBidirectional());
        return dto;
    }

    @Transactional(readOnly = true)
    public long countRelations(@Nullable String workspaceId) {
        if (workspaceId != null) {
            return relationRepository.countByWorkspaceIdIsNullOrWorkspaceId(workspaceId);
        }
        return relationRepository.count();
    }

    /** Legacy overload — counts all relations. */
    @Transactional(readOnly = true)
    public long countRelations() {
        return countRelations(null);
    }
}