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) {
}
}