ProjectPortfolioService.java
package com.taxonomy.portfolio.service;
import com.taxonomy.portfolio.dto.PortfolioDtos.CreateProjectRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.CreateRequirementRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.CreateRequirementVersionRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.ImportRequirementCandidate;
import com.taxonomy.portfolio.dto.PortfolioDtos.ProjectView;
import com.taxonomy.portfolio.dto.PortfolioDtos.RequirementVersionView;
import com.taxonomy.portfolio.dto.PortfolioDtos.RequirementView;
import com.taxonomy.portfolio.dto.PortfolioDtos.SourceReference;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateProjectRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateRequirementRequest;
import com.taxonomy.portfolio.model.ArchitectureProject;
import com.taxonomy.portfolio.model.PortfolioTypes.ConflictStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.Criticality;
import com.taxonomy.portfolio.model.PortfolioTypes.ProjectStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.RequirementStatus;
import com.taxonomy.portfolio.model.PortfolioTypes.RequirementType;
import com.taxonomy.portfolio.model.PortfolioTypes.ReviewStatus;
import com.taxonomy.portfolio.model.ProjectRequirement;
import com.taxonomy.portfolio.model.ProjectRequirementVersion;
import com.taxonomy.portfolio.repository.ArchitectureProjectRepository;
import com.taxonomy.portfolio.repository.ProjectConflictRepository;
import com.taxonomy.portfolio.repository.ProjectRequirementRepository;
import com.taxonomy.portfolio.repository.ProjectRequirementVersionRepository;
import com.taxonomy.portfolio.repository.ProjectSolutionRepository;
import com.taxonomy.workspace.service.WorkspaceContext;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;
/** Application service for exact-tenant projects and immutable requirement versions. */
@Service
public class ProjectPortfolioService {
private static final Pattern BUSINESS_KEY =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
private static final List<ConflictStatus> CLOSED_CONFLICT_STATUSES =
List.of(ConflictStatus.REJECTED, ConflictStatus.RESOLVED);
private final ArchitectureProjectRepository projectRepository;
private final ProjectRequirementRepository requirementRepository;
private final ProjectRequirementVersionRepository versionRepository;
private final ProjectSolutionRepository projectSolutionRepository;
private final ProjectConflictRepository conflictRepository;
private final PortfolioFingerprintService fingerprintService;
private final PortfolioJsonCodec jsonCodec;
public ProjectPortfolioService(ArchitectureProjectRepository projectRepository,
ProjectRequirementRepository requirementRepository,
ProjectRequirementVersionRepository versionRepository,
ProjectSolutionRepository projectSolutionRepository,
ProjectConflictRepository conflictRepository,
PortfolioFingerprintService fingerprintService,
PortfolioJsonCodec jsonCodec) {
this.projectRepository = projectRepository;
this.requirementRepository = requirementRepository;
this.versionRepository = versionRepository;
this.projectSolutionRepository = projectSolutionRepository;
this.conflictRepository = conflictRepository;
this.fingerprintService = fingerprintService;
this.jsonCodec = jsonCodec;
}
@Transactional
public ProjectView createProject(CreateProjectRequest request,
String username,
WorkspaceContext context) {
requireNonNull(request, "project request");
String scopeKey = PortfolioScope.key(username, context);
String projectKey = normalizeBusinessKey(request.projectKey(), "projectKey");
String title = requireText(request.title(), "title", 240);
if (projectRepository.existsByScopeKeyAndProjectKeyIgnoreCase(scopeKey, projectKey)) {
throw PortfolioException.conflict("Project key already exists in this workspace: " + projectKey);
}
var budgetAmount = PortfolioValueValidator.money(request.budgetAmount(), "budgetAmount");
String budgetCurrency = normalizeCurrency(request.budgetCurrency());
PortfolioValueValidator.requireMoneyPair(
budgetAmount, budgetCurrency, "budgetAmount", "budgetCurrency");
Instant now = Instant.now();
ArchitectureProject project = new ArchitectureProject(
scopeKey,
PortfolioScope.workspaceId(context),
PortfolioScope.username(username, context),
projectKey,
title,
limited(request.description(), 4000, "description"),
request.status() != null ? request.status() : ProjectStatus.PLANNING,
now);
project.update(
null,
null,
null,
limited(request.targetArchitecture(), 4000, "targetArchitecture"),
request.targetDate(),
budgetAmount,
budgetCurrency,
now);
return toProjectView(projectRepository.save(project));
}
@Transactional(readOnly = true)
public List<ProjectView> listProjects(String username, WorkspaceContext context) {
return projectRepository.findByScopeKeyOrderByUpdatedAtDesc(PortfolioScope.key(username, context))
.stream().map(this::toProjectView).toList();
}
@Transactional(readOnly = true)
public ProjectView getProject(Long projectId, String username, WorkspaceContext context) {
return toProjectView(requireProject(projectId, username, context));
}
@Transactional
public ProjectView updateProject(Long projectId,
UpdateProjectRequest request,
String username,
WorkspaceContext context) {
requireNonNull(request, "project update");
ArchitectureProject project = requireProjectForUpdate(projectId, username, context);
var requestedAmount = request.budgetAmount() != null
? PortfolioValueValidator.money(request.budgetAmount(), "budgetAmount") : null;
String requestedCurrency = request.budgetCurrency() != null
? normalizeCurrency(request.budgetCurrency()) : null;
var effectiveAmount = request.budgetAmount() != null
? requestedAmount : project.getBudgetAmount();
String effectiveCurrency = request.budgetCurrency() != null
? requestedCurrency : project.getBudgetCurrency();
PortfolioValueValidator.requireMoneyPair(
effectiveAmount, effectiveCurrency, "budgetAmount", "budgetCurrency");
project.update(
request.title() != null ? requireText(request.title(), "title", 240) : null,
limited(request.description(), 4000, "description"),
request.status(),
limited(request.targetArchitecture(), 4000, "targetArchitecture"),
request.targetDate(),
requestedAmount,
requestedCurrency,
Instant.now());
return toProjectView(project);
}
@Transactional
public RequirementView createRequirement(Long projectId,
CreateRequirementRequest request,
String username,
WorkspaceContext context) {
requireNonNull(request, "requirement request");
ArchitectureProject project = requireProjectForUpdate(projectId, username, context);
String scopeKey = project.getScopeKey();
String requirementKey = normalizeBusinessKey(request.requirementKey(), "requirementKey");
if (requirementRepository
.findByProjectIdAndScopeKeyAndRequirementKeyIgnoreCase(
projectId, scopeKey, requirementKey)
.isPresent()) {
throw PortfolioException.conflict(
"Requirement key already exists in project " + project.getProjectKey() + ": " + requirementKey);
}
int priority = normalizePriority(request.priority());
Instant now = Instant.now();
ProjectRequirement requirement = new ProjectRequirement(
project,
requirementKey,
requireText(request.title(), "title", 240),
request.status() != null ? request.status() : RequirementStatus.DRAFT,
priority,
request.criticality() != null ? request.criticality() : Criticality.MEDIUM,
request.requirementType() != null ? request.requirementType() : RequirementType.FUNCTIONAL,
request.reviewStatus() != null ? request.reviewStatus() : ReviewStatus.PROPOSED,
request.ownerUsername() != null && !request.ownerUsername().isBlank()
? requireText(request.ownerUsername(), "ownerUsername", 160)
: PortfolioScope.username(username, context),
now);
requirementRepository.save(requirement);
ProjectRequirementVersion version = createVersionEntity(
requirement,
requireText(request.text(), "text", 100_000),
request.changeReason(),
request.source(),
PortfolioScope.username(username, context),
1,
now);
versionRepository.save(version);
requirement.pointToVersion(version.getId(), now);
return toRequirementView(requirement, version);
}
@Transactional
public List<RequirementView> importRequirements(Long projectId,
List<ImportRequirementCandidate> candidates,
String username,
WorkspaceContext context) {
if (candidates == null || candidates.isEmpty()) {
throw PortfolioException.validation("At least one requirement candidate is required");
}
List<RequirementView> imported = new ArrayList<>(candidates.size());
for (ImportRequirementCandidate candidate : candidates) {
if (candidate == null) continue;
imported.add(createRequirement(projectId, new CreateRequirementRequest(
candidate.requirementKey(),
candidate.title(),
candidate.text(),
RequirementStatus.DRAFT,
candidate.priority(),
candidate.criticality(),
candidate.requirementType(),
ReviewStatus.PROPOSED,
username,
"Imported as an individual requirement candidate",
candidate.source()), username, context));
}
if (imported.isEmpty()) {
throw PortfolioException.validation("No valid requirement candidates were supplied");
}
return List.copyOf(imported);
}
@Transactional(readOnly = true)
public List<RequirementView> listRequirements(Long projectId,
String username,
WorkspaceContext context) {
ArchitectureProject project = requireProject(projectId, username, context);
return requirementRepository
.findByProjectIdAndScopeKeyOrderByRequirementKeyAsc(
projectId, project.getScopeKey())
.stream()
.map(requirement -> toRequirementView(requirement, currentVersion(requirement)))
.toList();
}
public record RequirementPage(List<RequirementView> requirements, boolean hasNext) {
public RequirementPage { requirements = List.copyOf(requirements); }
}
/** Bound approved reads in the database, including the exact project and tenant predicates. */
@Transactional(readOnly = true)
public RequirementPage listApprovedRequirements(Long projectId, String username,
WorkspaceContext context, int page, int pageSize) {
if (page < 0 || page > 100000 || pageSize < 1 || pageSize > 100)
throw PortfolioException.validation("Requirement page is outside supported bounds");
ArchitectureProject project = requireProject(projectId, username, context);
var slice = requirementRepository.findByProjectIdAndScopeKeyAndStatusOrderByRequirementKeyAscIdAsc(
projectId, project.getScopeKey(), RequirementStatus.APPROVED, PageRequest.of(page, pageSize));
return new RequirementPage(slice.getContent().stream()
.map(requirement -> toRequirementView(requirement, requirement.getCurrentVersion() != null
? requirement.getCurrentVersion() : currentVersion(requirement)))
.toList(), slice.hasNext());
}
@Transactional(readOnly = true)
public RequirementView getRequirement(Long projectId,
Long requirementId,
String username,
WorkspaceContext context) {
ProjectRequirement requirement = requireRequirement(projectId, requirementId, username, context);
return toRequirementView(requirement, currentVersion(requirement));
}
@Transactional
public RequirementView updateRequirement(Long projectId,
Long requirementId,
UpdateRequirementRequest request,
String username,
WorkspaceContext context) {
requireNonNull(request, "requirement update");
ProjectRequirement requirement = requireRequirementForUpdate(projectId, requirementId, username, context);
requirement.updateMetadata(
request.title() != null ? requireText(request.title(), "title", 240) : null,
request.status(),
request.priority() != null ? normalizePriority(request.priority()) : null,
request.criticality(),
request.requirementType(),
request.reviewStatus(),
request.ownerUsername() != null && !request.ownerUsername().isBlank()
? requireText(request.ownerUsername(), "ownerUsername", 160) : null,
Instant.now());
return toRequirementView(requirement, currentVersion(requirement));
}
@Transactional
public RequirementVersionView addRequirementVersion(Long projectId,
Long requirementId,
CreateRequirementVersionRequest request,
String username,
WorkspaceContext context) {
requireNonNull(request, "requirement version request");
ProjectRequirement requirement = requireRequirementForUpdate(
projectId, requirementId, username, context);
String scopeKey = requirement.getScopeKey();
String text = requireText(request.text(), "text", 100_000);
String contentHash = fingerprintService.contentFingerprint(text);
ProjectRequirementVersion existing = versionRepository
.findByRequirementIdAndScopeKeyAndContentHash(
requirementId, scopeKey, contentHash)
.orElse(null);
if (existing != null) {
requirement.pointToVersion(existing.getId(), Instant.now());
return toVersionView(existing);
}
int nextVersion = versionRepository
.findFirstByRequirementIdAndScopeKeyOrderByVersionNumberDesc(
requirementId, scopeKey)
.map(last -> last.getVersionNumber() + 1)
.orElse(1);
Instant now = Instant.now();
ProjectRequirementVersion version = createVersionEntity(
requirement,
text,
request.changeReason(),
request.source(),
PortfolioScope.username(username, context),
nextVersion,
now);
versionRepository.save(version);
requirement.pointToVersion(version.getId(), now);
return toVersionView(version);
}
@Transactional(readOnly = true)
public List<RequirementVersionView> listRequirementVersions(Long projectId,
Long requirementId,
String username,
WorkspaceContext context) {
ProjectRequirement requirement = requireRequirement(
projectId, requirementId, username, context);
return versionRepository
.findByRequirementIdAndScopeKeyOrderByVersionNumberDesc(
requirementId, requirement.getScopeKey())
.stream().map(this::toVersionView).toList();
}
@Transactional(readOnly = true)
public ArchitectureProject requireProject(Long projectId,
String username,
WorkspaceContext context) {
if (projectId == null) throw PortfolioException.validation("projectId is required");
return projectRepository.findByIdAndScopeKey(projectId, PortfolioScope.key(username, context))
.orElseThrow(() -> PortfolioException.notFound("Project not found: " + projectId));
}
@Transactional(readOnly = true)
public ProjectRequirement requireRequirement(Long projectId,
Long requirementId,
String username,
WorkspaceContext context) {
ArchitectureProject project = requireProject(projectId, username, context);
if (requirementId == null) throw PortfolioException.validation("requirementId is required");
return requirementRepository
.findByIdAndProjectIdAndScopeKey(
requirementId, projectId, project.getScopeKey())
.orElseThrow(() -> PortfolioException.notFound(
"Requirement " + requirementId + " was not found in project " + projectId));
}
private ProjectRequirement requireRequirementForUpdate(Long projectId,
Long requirementId,
String username,
WorkspaceContext context) {
ArchitectureProject project = requireProjectForUpdate(projectId, username, context);
if (requirementId == null) throw PortfolioException.validation("requirementId is required");
return requirementRepository
.findByIdAndProjectIdAndScopeKeyForUpdate(
requirementId, projectId, project.getScopeKey())
.orElseThrow(() -> PortfolioException.notFound(
"Requirement " + requirementId + " was not found in project " + projectId));
}
/** Common aggregate lock, including integration previews applied against a frozen project fingerprint. */
@Transactional
public ArchitectureProject requireProjectForUpdate(Long projectId, String username, WorkspaceContext context) {
if (projectId == null) throw PortfolioException.validation("projectId is required");
return projectRepository.findByIdAndScopeKeyForUpdate(projectId, PortfolioScope.key(username, context))
.orElseThrow(() -> PortfolioException.notFound("Project not found: " + projectId));
}
@Transactional(readOnly = true)
public ProjectRequirementVersion currentVersion(ProjectRequirement requirement) {
Objects.requireNonNull(requirement, "requirement");
String scopeKey = requirement.getScopeKey();
if (requirement.getCurrentVersionId() != null) {
return versionRepository.findByIdAndRequirementIdAndScopeKey(
requirement.getCurrentVersionId(), requirement.getId(), scopeKey)
.orElseThrow(() -> PortfolioException.notFound(
"Current requirement version not found: " + requirement.getCurrentVersionId()));
}
return versionRepository
.findFirstByRequirementIdAndScopeKeyOrderByVersionNumberDesc(
requirement.getId(), scopeKey)
.orElseThrow(() -> PortfolioException.notFound(
"Requirement has no text version: " + requirement.getRequirementKey()));
}
@Transactional(readOnly = true)
public ProjectView toProjectView(ArchitectureProject project) {
int requirementCount = Math.toIntExact(
requirementRepository.countByProjectIdAndScopeKey(
project.getId(), project.getScopeKey()));
int solutionCount = Math.toIntExact(projectSolutionRepository.countByProjectId(project.getId()));
int openConflicts = Math.toIntExact(conflictRepository.countByProjectIdAndStatusNotIn(
project.getId(), CLOSED_CONFLICT_STATUSES));
return new ProjectView(
project.getId(),
project.getProjectKey(),
project.getTitle(),
project.getDescription(),
project.getStatus(),
project.getOwnerUsername(),
project.getWorkspaceId(),
project.getTargetArchitecture(),
project.getTargetDate(),
project.getBudgetAmount(),
project.getBudgetCurrency(),
project.getCreatedAt(),
project.getUpdatedAt(),
requirementCount,
solutionCount,
openConflicts);
}
public RequirementView toRequirementView(ProjectRequirement requirement,
ProjectRequirementVersion currentVersion) {
return new RequirementView(
requirement.getId(),
requirement.getProject().getId(),
requirement.getRequirementKey(),
requirement.getTitle(),
requirement.getStatus(),
requirement.getPriority(),
requirement.getCriticality(),
requirement.getRequirementType(),
requirement.getReviewStatus(),
requirement.getOwnerUsername(),
requirement.getCurrentVersionId(),
requirement.getCurrentAnalysisSnapshotId(),
requirement.getCreatedAt(),
requirement.getUpdatedAt(),
currentVersion != null ? toVersionView(currentVersion) : null);
}
public RequirementVersionView toVersionView(ProjectRequirementVersion version) {
return new RequirementVersionView(
version.getId(),
version.getVersionNumber(),
version.getText(),
version.getContentHash(),
version.getChangeReason(),
version.getCreatedBy(),
version.getCreatedAt(),
new SourceReference(
version.getSourceArtifactId(),
version.getSourceVersionId(),
jsonCodec.readLongList(version.getSourceFragmentIdsJson()),
version.getSectionReference(),
version.getPageNumber(),
version.getOriginalText()));
}
private ProjectRequirementVersion createVersionEntity(ProjectRequirement requirement,
String text,
String changeReason,
SourceReference source,
String createdBy,
int versionNumber,
Instant now) {
List<Long> fragmentIds = sourceFragmentIds(source);
Long sourceArtifactId = source != null
? positiveIdentifier(source.sourceArtifactId(), "sourceArtifactId") : null;
Long sourceVersionId = source != null
? positiveIdentifier(source.sourceVersionId(), "sourceVersionId") : null;
Integer pageNumber = source != null ? positivePageNumber(source.pageNumber()) : null;
return new ProjectRequirementVersion(
requirement,
versionNumber,
text,
fingerprintService.contentFingerprint(text),
limited(changeReason, 1000, "changeReason"),
createdBy,
now,
sourceArtifactId,
sourceVersionId,
jsonCodec.write(fragmentIds),
source != null ? limited(source.sectionReference(), 500, "sectionReference") : null,
pageNumber,
source != null
? limited(source.originalText(),
PortfolioValueValidator.MAX_ORIGINAL_TEXT_CHARACTERS,
"originalText")
: null);
}
private static List<Long> sourceFragmentIds(SourceReference source) {
if (source == null || source.sourceFragmentIds() == null) return List.of();
if (source.sourceFragmentIds().size() > PortfolioValueValidator.MAX_SOURCE_FRAGMENT_IDS) {
throw PortfolioException.validation(
"sourceFragmentIds contains more than "
+ PortfolioValueValidator.MAX_SOURCE_FRAGMENT_IDS + " entries");
}
List<Long> fragmentIds = source.sourceFragmentIds().stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (fragmentIds.stream().anyMatch(id -> id <= 0)) {
throw PortfolioException.validation("sourceFragmentIds must contain only positive identifiers");
}
return fragmentIds;
}
private static Long positiveIdentifier(Long value, String field) {
if (value != null && value <= 0) {
throw PortfolioException.validation(field + " must be a positive identifier");
}
return value;
}
private static Integer positivePageNumber(Integer value) {
if (value != null && value < 1) {
throw PortfolioException.validation("pageNumber must be positive");
}
return value;
}
private static int normalizePriority(Integer priority) {
int normalized = priority != null ? priority : 50;
if (normalized < 0 || normalized > 100) {
throw PortfolioException.validation("priority must be between 0 and 100");
}
return normalized;
}
private static String normalizeBusinessKey(String value, String field) {
String normalized = requireText(value, field, 64).toUpperCase(Locale.ROOT);
if (!BUSINESS_KEY.matcher(normalized).matches()) {
throw PortfolioException.validation(
field + " must start with a letter or digit and contain only letters, digits, '.', '_' or '-'");
}
return normalized;
}
private static String normalizeCurrency(String currency) {
return PortfolioValueValidator.currency(currency, "budgetCurrency");
}
static String requireText(String value, String field, int maximumLength) {
if (value == null || value.isBlank()) {
throw PortfolioException.validation(field + " must not be blank");
}
String normalized = value.strip();
if (normalized.length() > maximumLength) {
throw PortfolioException.validation(field + " exceeds " + maximumLength + " characters");
}
return normalized;
}
static String limited(String value, int maximumLength, String field) {
if (value == null) return null;
String normalized = value.strip();
if (normalized.length() > maximumLength) {
throw PortfolioException.validation(field + " exceeds " + maximumLength + " characters");
}
return normalized;
}
private static void requireNonNull(Object value, String label) {
if (value == null) throw PortfolioException.validation(label + " is required");
}
}