ProjectPortfolioController.java

package com.taxonomy.portfolio.controller;

import com.taxonomy.portfolio.dto.PortfolioDtos.AnalyzeProjectRequest;
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.ImportRequirementsRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.ImportRequirementsResult;
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.UpdateProjectRequest;
import com.taxonomy.portfolio.dto.PortfolioDtos.UpdateRequirementRequest;
import com.taxonomy.portfolio.service.PortfolioException;
import com.taxonomy.portfolio.service.ProjectPortfolioService;
import com.taxonomy.portfolio.service.ProjectRequirementAnalysisService;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.util.List;

/** Project and stable requirement identities with immutable text versions. */
@RestController
@RequestMapping("/api/projects")
@Tag(name = "Project Requirement Portfolio")
public class ProjectPortfolioController {

    private final ProjectPortfolioService projectService;
    private final ProjectRequirementAnalysisService analysisService;
    private final WorkspaceResolver workspaceResolver;
    private final int maximumImportRequirements;
    private final long maximumImportCharacters;

    public ProjectPortfolioController(ProjectPortfolioService projectService,
                                      ProjectRequirementAnalysisService analysisService,
                                      WorkspaceResolver workspaceResolver,
                                      @Value("${taxonomy.portfolio.max-import-requirements:100}")
                                      int maximumImportRequirements,
                                      @Value("${taxonomy.portfolio.max-import-characters:500000}")
                                      long maximumImportCharacters) {
        this.projectService = projectService;
        this.analysisService = analysisService;
        this.workspaceResolver = workspaceResolver;
        this.maximumImportRequirements = Math.max(1, maximumImportRequirements);
        this.maximumImportCharacters = Math.max(1L, maximumImportCharacters);
    }

    @PostMapping
    @Operation(summary = "Create a project")
    public ResponseEntity<ProjectView> createProject(@RequestBody CreateProjectRequest request) {
        RequestScope scope = scope();
        ProjectView project = projectService.createProject(request, scope.username(), scope.context());
        return ResponseEntity.created(URI.create("/api/projects/" + project.id())).body(project);
    }

    @GetMapping
    @Operation(summary = "List projects in the current workspace")
    public List<ProjectView> listProjects() {
        RequestScope scope = scope();
        return projectService.listProjects(scope.username(), scope.context());
    }

    @GetMapping("/{projectId}")
    @Operation(summary = "Read one project")
    public ProjectView getProject(@PathVariable Long projectId) {
        RequestScope scope = scope();
        return projectService.getProject(projectId, scope.username(), scope.context());
    }

    @PatchMapping("/{projectId}")
    @Operation(summary = "Update project metadata")
    public ProjectView updateProject(@PathVariable Long projectId,
                                     @RequestBody UpdateProjectRequest request) {
        RequestScope scope = scope();
        return projectService.updateProject(projectId, request, scope.username(), scope.context());
    }

    @PostMapping("/{projectId}/requirements")
    @Operation(summary = "Create one project requirement and its initial immutable version")
    public ResponseEntity<RequirementView> createRequirement(
            @PathVariable Long projectId,
            @RequestBody CreateRequirementRequest request) {
        RequestScope scope = scope();
        RequirementView requirement = projectService.createRequirement(
                projectId, request, scope.username(), scope.context());
        return ResponseEntity.created(URI.create(
                "/api/projects/" + projectId + "/requirements/" + requirement.id()))
                .body(requirement);
    }

    @PostMapping("/{projectId}/requirements/import")
    @Operation(summary = "Import candidates as separate requirements, optionally queue their analysis")
    public ResponseEntity<ImportRequirementsResult> importRequirements(
            @PathVariable Long projectId,
            @RequestBody ImportRequirementsRequest request) {
        validateImportRequest(request);
        RequestScope scope = scope();
        List<RequirementView> requirements = projectService.importRequirements(
                projectId, request.requirements(), scope.username(), scope.context());
        var job = request.analyzeAfterImport()
                ? analysisService.enqueueProject(
                        projectId,
                        new AnalyzeProjectRequest(
                                requirements.stream().map(RequirementView::id).toList(),
                                false,
                                request.provider(),
                                request.maxArchitectureNodes(),
                                request.idempotencyKey()),
                        scope.username(),
                        scope.context())
                : null;
        return ResponseEntity.status(201).body(new ImportRequirementsResult(requirements, job));
    }

    @GetMapping("/{projectId}/requirements")
    @Operation(summary = "List project requirements")
    public List<RequirementView> listRequirements(@PathVariable Long projectId) {
        RequestScope scope = scope();
        return projectService.listRequirements(projectId, scope.username(), scope.context());
    }

    @GetMapping("/{projectId}/requirements/{requirementId}")
    @Operation(summary = "Read one project requirement")
    public RequirementView getRequirement(@PathVariable Long projectId,
                                          @PathVariable Long requirementId) {
        RequestScope scope = scope();
        return projectService.getRequirement(
                projectId, requirementId, scope.username(), scope.context());
    }

    @PatchMapping("/{projectId}/requirements/{requirementId}")
    @Operation(summary = "Update requirement metadata without rewriting historical text")
    public RequirementView updateRequirement(@PathVariable Long projectId,
                                             @PathVariable Long requirementId,
                                             @RequestBody UpdateRequirementRequest request) {
        RequestScope scope = scope();
        return projectService.updateRequirement(
                projectId, requirementId, request, scope.username(), scope.context());
    }

    @PostMapping("/{projectId}/requirements/{requirementId}/versions")
    @Operation(summary = "Create or select an immutable requirement text version")
    public ResponseEntity<RequirementVersionView> addRequirementVersion(
            @PathVariable Long projectId,
            @PathVariable Long requirementId,
            @RequestBody CreateRequirementVersionRequest request) {
        RequestScope scope = scope();
        RequirementVersionView version = projectService.addRequirementVersion(
                projectId, requirementId, request, scope.username(), scope.context());
        return ResponseEntity.created(URI.create(
                "/api/projects/" + projectId + "/requirements/" + requirementId
                        + "/versions/" + version.id()))
                .body(version);
    }

    @GetMapping("/{projectId}/requirements/{requirementId}/versions")
    @Operation(summary = "List immutable requirement versions")
    public List<RequirementVersionView> listRequirementVersions(
            @PathVariable Long projectId,
            @PathVariable Long requirementId) {
        RequestScope scope = scope();
        return projectService.listRequirementVersions(
                projectId, requirementId, scope.username(), scope.context());
    }

    private void validateImportRequest(ImportRequirementsRequest request) {
        if (request == null || request.requirements() == null || request.requirements().isEmpty()) {
            throw PortfolioException.validation("At least one requirement candidate is required");
        }
        if (request.requirements().size() > maximumImportRequirements) {
            throw PortfolioException.validation(
                    "Import contains " + request.requirements().size()
                            + " candidates; maximum is " + maximumImportRequirements);
        }

        long characters = 0L;
        for (ImportRequirementCandidate candidate : request.requirements()) {
            if (candidate == null) continue;
            characters += length(candidate.text());
            if (candidate.source() != null) {
                characters += length(candidate.source().originalText());
            }
            if (characters > maximumImportCharacters) {
                throw PortfolioException.validation(
                        "Import text payload exceeds " + maximumImportCharacters + " characters");
            }
        }
    }

    private static int length(String value) {
        return value != null ? value.length() : 0;
    }

    private RequestScope scope() {
        return new RequestScope(
                workspaceResolver.resolveCurrentUsername(),
                workspaceResolver.resolveCurrentContext());
    }

    private record RequestScope(String username, WorkspaceContext context) {
    }
}