AnalysisWorkingDraftService.java

package com.taxonomy.analysis.session;

import com.taxonomy.analysis.session.AnalysisDraftDtos.AnalysisDraftView;
import com.taxonomy.analysis.session.AnalysisDraftDtos.ResetAnalysisDraftRequest;
import com.taxonomy.analysis.session.AnalysisDraftDtos.SaveAnalysisDraftRequest;
import com.taxonomy.workspace.model.SystemRepository;
import com.taxonomy.workspace.model.UserWorkspace;
import com.taxonomy.workspace.service.SystemRepositoryService;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceManager;
import com.taxonomy.workspace.service.WorkspaceScope;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;

import java.time.Instant;
import java.util.Optional;

/**
 * Persists and restores the ad-hoc analysis working draft in an exact workspace,
 * repository and branch scope.
 */
@Service
public class AnalysisWorkingDraftService {

    private static final int PAYLOAD_SCHEMA_VERSION = 1;
    private static final String EMPTY_DRAFT_STATE = "EMPTY";

    private final AnalysisWorkingDraftRepository repository;
    private final WorkspaceManager workspaceManager;
    private final SystemRepositoryService systemRepositoryService;
    private final ObjectMapper objectMapper;
    private final int maximumPayloadCharacters;

    @Autowired
    public AnalysisWorkingDraftService(
            AnalysisWorkingDraftRepository repository,
            WorkspaceManager workspaceManager,
            SystemRepositoryService systemRepositoryService,
            ObjectMapper objectMapper,
            @Value("${taxonomy.analysis-draft.max-characters:2000000}")
            int maximumPayloadCharacters) {
        this.repository = repository;
        this.workspaceManager = workspaceManager;
        this.systemRepositoryService = systemRepositoryService;
        this.objectMapper = objectMapper;
        this.maximumPayloadCharacters = Math.max(10_000, maximumPayloadCharacters);
    }

    @Transactional(readOnly = true)
    public Optional<AnalysisDraftView> read(String username, String workspaceId) {
        DraftScope scope = resolveScope(username, workspaceId);
        return repository.findByScopeKeyAndUsername(scope.scopeKey(), scope.username())
                .map(draft -> toView(draft, scope.branch()));
    }

    @Transactional
    public AnalysisDraftView save(String username,
                                  String workspaceId,
                                  SaveAnalysisDraftRequest request) {
        if (request == null || request.payload() == null || request.payload().isNull()
                || !request.payload().isObject()) {
            throw new AnalysisDraftValidationException(
                    "Analysis draft payload must be a JSON object");
        }

        DraftScope scope = resolveScope(username, workspaceId);
        String payloadJson = serialize(request.payload());
        if (payloadJson.length() > maximumPayloadCharacters) {
            throw new AnalysisDraftValidationException(
                    "Analysis draft exceeds " + maximumPayloadCharacters + " characters");
        }

        AnalysisWorkingDraft draft = repository
                .findByScopeKeyAndUsername(scope.scopeKey(), scope.username())
                .orElse(null);
        Instant now = Instant.now();

        if (draft == null) {
            if (request.expectedVersion() != null) {
                throw conflict(request.expectedVersion(), null);
            }
            draft = new AnalysisWorkingDraft(
                    scope.scopeKey(), scope.workspaceId(), scope.username(), payloadJson, now);
        } else {
            requireExpectedVersion(draft, request.expectedVersion());
            draft.replacePayload(payloadJson, now);
        }

        try {
            return toView(repository.saveAndFlush(draft), scope.branch());
        } catch (OptimisticLockingFailureException | DataIntegrityViolationException exception) {
            throw new AnalysisDraftConflictException(
                    "The analysis draft was changed by another session", exception);
        }
    }

    /**
     * Replaces the current working draft with a versioned empty tombstone.
     *
     * <p>This command deliberately ignores a browser-supplied expected version:
     * a user must be able to start over from any logged-in tab. The persisted
     * empty revision is important because deleting the row makes an older tab
     * look like the first writer again and allows stale requirement text to be
     * recreated accidentally.</p>
     */
    @Transactional
    public AnalysisDraftView reset(String username,
                                   String workspaceId,
                                   ResetAnalysisDraftRequest request) {
        DraftScope scope = resolveScope(username, workspaceId);
        JsonNode payload = emptyPayload(request);
        String payloadJson = serialize(payload);
        if (payloadJson.length() > maximumPayloadCharacters) {
            throw new AnalysisDraftValidationException(
                    "Analysis draft reset exceeds " + maximumPayloadCharacters + " characters");
        }

        AnalysisWorkingDraft draft = repository
                .findByScopeKeyAndUsername(scope.scopeKey(), scope.username())
                .orElse(null);
        Instant now = Instant.now();
        if (draft == null) {
            draft = new AnalysisWorkingDraft(
                    scope.scopeKey(), scope.workspaceId(), scope.username(), payloadJson, now);
        } else {
            draft.replacePayload(payloadJson, now);
        }

        try {
            return toView(repository.saveAndFlush(draft), scope.branch());
        } catch (OptimisticLockingFailureException | DataIntegrityViolationException exception) {
            throw new AnalysisDraftConflictException(
                    "The analysis draft changed while it was being reset", exception);
        }
    }

    @Transactional
    public void delete(String username, String workspaceId, Long expectedVersion) {
        DraftScope scope = resolveScope(username, workspaceId);
        AnalysisWorkingDraft draft = repository
                .findByScopeKeyAndUsername(scope.scopeKey(), scope.username())
                .orElse(null);
        if (draft == null) return;
        requireExpectedVersion(draft, expectedVersion);
        try {
            repository.delete(draft);
            repository.flush();
        } catch (OptimisticLockingFailureException exception) {
            throw new AnalysisDraftConflictException(
                    "The analysis draft was changed by another session", exception);
        }
    }

    private DraftScope resolveScope(String username, String workspaceId) {
        if (username == null || username.isBlank()) {
            throw new AccessDeniedException("An authenticated user is required");
        }
        if (workspaceId == null || workspaceId.isBlank()) {
            throw new AnalysisDraftValidationException("workspaceId is required");
        }

        String requestedWorkspace = workspaceId.strip();
        UserWorkspace workspace = workspaceManager.getWorkspaceById(requestedWorkspace);
        if (workspace == null
                || workspace.getUsername() == null
                || !workspace.getUsername().equals(username)
                || workspace.isArchived()
                || workspace.isShared()) {
            throw new AccessDeniedException("Workspace is not available to the current user");
        }

        SystemRepository sourceRepository = resolveSourceRepository(workspace);
        String repositoryId = requireText(
                sourceRepository.getRepositoryId(), "workspace source repository ID");
        String branch = hasText(workspace.getCurrentBranch())
                ? workspace.getCurrentBranch().strip()
                : requireText(sourceRepository.getDefaultBranch(),
                        "workspace source repository branch");

        WorkspaceContext context = new WorkspaceContext(
                username, requestedWorkspace, branch, repositoryId);
        return new DraftScope(
                WorkspaceScope.key(username, context),
                WorkspaceScope.username(username, context),
                requestedWorkspace,
                branch);
    }

    private SystemRepository resolveSourceRepository(UserWorkspace workspace) {
        if (!hasText(workspace.getSourceRepositoryId())) {
            return systemRepositoryService.getPrimaryRepository();
        }
        String repositoryId = workspace.getSourceRepositoryId().strip();
        try {
            return systemRepositoryService.getRepository(repositoryId);
        } catch (IllegalArgumentException exception) {
            throw new IllegalStateException(
                    "Workspace source repository is not available: " + repositoryId,
                    exception);
        }
    }

    private void requireExpectedVersion(AnalysisWorkingDraft draft, Long expectedVersion) {
        if (expectedVersion == null || expectedVersion.longValue() != draft.getRowVersion()) {
            throw conflict(expectedVersion, draft.getRowVersion());
        }
    }

    private AnalysisDraftConflictException conflict(Long expected, Long actual) {
        return new AnalysisDraftConflictException(
                "Analysis draft version conflict: expected "
                        + (expected != null ? expected : "none")
                        + ", current " + (actual != null ? actual : "none"));
    }

    private String serialize(JsonNode payload) {
        try {
            return objectMapper.writeValueAsString(payload);
        } catch (JacksonException exception) {
            throw new AnalysisDraftValidationException(
                    "Analysis draft payload is not valid JSON", exception);
        }
    }

    private JsonNode emptyPayload(ResetAnalysisDraftRequest request) {
        JsonNode analysisOptions = request != null ? request.analysisOptions() : null;
        if (analysisOptions != null && !analysisOptions.isNull()
                && !analysisOptions.isObject()) {
            throw new AnalysisDraftValidationException(
                    "Analysis draft reset options must be a JSON object");
        }

        ObjectNode payload = objectMapper.createObjectNode();
        payload.put("schemaVersion", PAYLOAD_SCHEMA_VERSION);
        payload.put("draftState", EMPTY_DRAFT_STATE);
        payload.put("businessText", "");
        payload.putNull("lastAnalyzedText");
        payload.putNull("storedBusinessText");
        payload.putNull("scores");
        payload.set("reasons", objectMapper.createObjectNode());
        payload.set("discrepancies", objectMapper.createArrayNode());
        payload.putNull("architectureView");
        payload.set("provisionalRelations", objectMapper.createArrayNode());
        payload.set("evaluatedNodes", objectMapper.createArrayNode());
        payload.put("currentView", "list");
        if (analysisOptions != null && !analysisOptions.isNull()) {
            payload.set("analysisOptions", analysisOptions.deepCopy());
        }
        return payload;
    }

    private AnalysisDraftView toView(AnalysisWorkingDraft draft, String branch) {
        try {
            return new AnalysisDraftView(
                    draft.getWorkspaceId(),
                    branch,
                    objectMapper.readTree(draft.getPayloadJson()),
                    draft.getRowVersion(),
                    draft.getUpdatedAt());
        } catch (JacksonException exception) {
            throw new IllegalStateException(
                    "Persisted analysis draft contains invalid JSON", exception);
        }
    }

    private static String requireText(String value, String label) {
        if (!hasText(value)) {
            throw new IllegalStateException(label + " is not configured");
        }
        return value.strip();
    }

    private static boolean hasText(String value) {
        return value != null && !value.isBlank();
    }

    private record DraftScope(
            String scopeKey,
            String username,
            String workspaceId,
            String branch) {
    }
}