Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.roleimpact.workspace.editor.api;

import java.util.Set;
import java.util.UUID;

import com.roleimpact.shared.model.Sensitivity;
Expand All @@ -12,5 +13,9 @@ public record RoleRequest(
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 2000) String description,
@NotNull Sensitivity sensitivity,
UUID ownerMemberId) {
UUID ownerMemberId,
Set<UUID> holderMemberIds) {
public RoleRequest {
if (holderMemberIds != null) holderMemberIds = Set.copyOf(holderMemberIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ public DraftCatalogResource updateMember(UUID workspaceId, UUID memberId, Member
public DraftCatalogResource createRole(UUID workspaceId, RoleRequest request) {
requireDraft(workspaceId);
if (request.ownerMemberId() != null) requireMember(workspaceId, request.ownerMemberId());
mutate(() -> repository.insertRole(workspaceId, request), "A role with this name already exists");
requireHolders(workspaceId, request.holderMemberIds());
UUID roleId = mutate(() -> repository.insertRole(workspaceId, request), "A role with this name already exists");
if (request.holderMemberIds() != null) repository.replaceRoleHolders(roleId, request.holderMemberIds());
touch(workspaceId);
return repository.findCatalog(workspaceId);
}
Expand All @@ -119,8 +121,10 @@ public DraftCatalogResource updateRole(UUID workspaceId, UUID roleId, RoleReques
requireDraft(workspaceId);
requireRole(workspaceId, roleId);
if (request.ownerMemberId() != null) requireMember(workspaceId, request.ownerMemberId());
requireHolders(workspaceId, request.holderMemberIds());
mutate(() -> { repository.updateRole(workspaceId, roleId, request); return roleId; },
"A role with this name already exists");
if (request.holderMemberIds() != null) repository.replaceRoleHolders(roleId, request.holderMemberIds());
touch(workspaceId);
return repository.findCatalog(workspaceId);
}
Expand Down Expand Up @@ -214,15 +218,24 @@ private void requireRole(UUID workspaceId, UUID roleId) {
}
}

private void requireHolders(UUID workspaceId, Set<UUID> holderMemberIds) {
if (holderMemberIds == null || repository.countMatchingMembers(workspaceId, holderMemberIds) == holderMemberIds.size()) return;
UUID unknownMemberId = holderMemberIds.stream()
.filter(memberId -> !repository.memberExists(workspaceId, memberId))
.findFirst()
.orElseThrow();
throw new DraftCatalogNotFoundException("Member", unknownMemberId);
}

private void requireCoverage(int minimumActors, int resilienceTarget) {
if (resilienceTarget < minimumActors) {
throw new DraftCatalogConflictException("Healthy coverage cannot be lower than the minimum people required");
}
}

private void mutate(Supplier<UUID> mutation, String conflictMessage) {
private UUID mutate(Supplier<UUID> mutation, String conflictMessage) {
try {
mutation.get();
return mutation.get();
}
catch (DataIntegrityViolationException exception) {
throw new DraftCatalogConflictException(conflictMessage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,23 @@ public int countMatchingRoles(UUID workspaceId, Set<UUID> roleIds) {
.param("workspaceId", workspaceId).param("roleIds", roleIds).query(Integer.class).single();
}

public int countMatchingMembers(UUID workspaceId, Set<UUID> memberIds) {
if (memberIds.isEmpty()) return 0;
return jdbcClient.sql("SELECT COUNT(*) FROM employees WHERE organization_id = :workspaceId AND id IN (:memberIds)")
.param("workspaceId", workspaceId).param("memberIds", memberIds).query(Integer.class).single();
}

public void replaceRoleHolders(UUID roleId, Set<UUID> memberIds) {
jdbcClient.sql("DELETE FROM employee_roles WHERE role_id = :roleId")
.param("roleId", roleId).update();
for (UUID memberId : memberIds) {
jdbcClient.sql("""
INSERT INTO employee_roles (employee_id, role_id, assigned_at, assigned_by)
VALUES (:memberId, :roleId, CURRENT_TIMESTAMP, 'workspace editor')
""").param("memberId", memberId).param("roleId", roleId).update();
}
}

public void replaceAssignments(UUID memberId, Set<UUID> roleIds) {
jdbcClient.sql("DELETE FROM employee_roles WHERE employee_id = :memberId").param("memberId", memberId).update();
for (UUID roleId : roleIds) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.roleimpact.workspace.preview.api;

import java.util.List;
import java.util.UUID;

import com.roleimpact.impactengine.ImpactResult.WorkflowStatus;
import com.roleimpact.shared.model.WorkflowCriticality;

public record DraftContinuityRiskResource(
String key,
UUID workflowId,
String workflowName,
WorkflowCriticality criticality,
UUID requirementId,
String requirementName,
int minimumActors,
int resilienceTarget,
UUID roleId,
String roleName,
List<EligibleMember> eligibleMembers,
List<MemberScenario> members) {

public DraftContinuityRiskResource {
eligibleMembers = List.copyOf(eligibleMembers);
members = List.copyOf(members);
}

public record EligibleMember(UUID id, String name) {
}

public record MemberScenario(
UUID id,
String name,
boolean eligible,
boolean losesCoverage,
int remainingEligibleActorCount,
WorkflowStatus scenarioStatus) {
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package com.roleimpact.workspace.preview.api;

import java.util.UUID;
import java.util.List;

import com.roleimpact.impactengine.ImpactResult;
import com.roleimpact.workspace.preview.application.DraftContinuityProjectionService;
import com.roleimpact.workspace.preview.application.DraftImpactPreviewService;

import jakarta.validation.Valid;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -18,9 +21,18 @@
public class DraftImpactPreviewController {

private final DraftImpactPreviewService service;
private final DraftContinuityProjectionService continuityProjectionService;

public DraftImpactPreviewController(DraftImpactPreviewService service) {
public DraftImpactPreviewController(
DraftImpactPreviewService service,
DraftContinuityProjectionService continuityProjectionService) {
this.service = service;
this.continuityProjectionService = continuityProjectionService;
}

@GetMapping("/continuity")
public List<DraftContinuityRiskResource> continuity(@PathVariable UUID workspaceId) {
return continuityProjectionService.project(workspaceId);
}

@PostMapping
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package com.roleimpact.workspace.preview.application;

import java.util.Comparator;
import java.util.List;
import java.util.UUID;

import com.roleimpact.catalog.snapshot.OrganizationSnapshot;
import com.roleimpact.catalog.snapshot.OrganizationSnapshotAssembler;
import com.roleimpact.impactengine.ImpactEngine;
import com.roleimpact.impactengine.ImpactResult;
import com.roleimpact.impactengine.RevokeEmployeeRole;
import com.roleimpact.shared.model.WorkflowCriticality;
import com.roleimpact.workspace.application.WorkspaceNotFoundException;
import com.roleimpact.workspace.editor.api.DraftCatalogResource;
import com.roleimpact.workspace.editor.application.PublishedWorkspaceMutationException;
import com.roleimpact.workspace.editor.persistence.DraftCatalogRepository;
import com.roleimpact.workspace.preview.api.DraftContinuityRiskResource;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class DraftContinuityProjectionService {

private static final Comparator<String> TEXT_ORDER = Comparator.nullsFirst(String::compareTo);
private static final Comparator<UUID> ID_ORDER = Comparator.nullsFirst(UUID::compareTo);

private final DraftCatalogRepository catalogRepository;
private final OrganizationSnapshotAssembler snapshotAssembler;
private final ImpactEngine impactEngine;

public DraftContinuityProjectionService(
DraftCatalogRepository catalogRepository,
OrganizationSnapshotAssembler snapshotAssembler,
ImpactEngine impactEngine) {
this.catalogRepository = catalogRepository;
this.snapshotAssembler = snapshotAssembler;
this.impactEngine = impactEngine;
}

@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ)
public List<DraftContinuityRiskResource> project(UUID workspaceId) {
validateDraftWorkspace(workspaceId);
DraftCatalogResource catalog = catalogRepository.findCatalog(workspaceId);
OrganizationSnapshot snapshot = snapshotAssembler.assemble(workspaceId);
return catalog.workflows().stream()
.sorted(Comparator.comparing(DraftCatalogResource.WorkflowItem::name, TEXT_ORDER)
.thenComparing(DraftCatalogResource.WorkflowItem::id, ID_ORDER))
.flatMap(workflow -> projectWorkflow(catalog, snapshot, workflow).stream())
.toList();
}

private List<DraftContinuityRiskResource> projectWorkflow(
DraftCatalogResource catalog,
OrganizationSnapshot snapshot,
DraftCatalogResource.WorkflowItem workflow) {
return workflow.requirements().stream()
.sorted(Comparator.comparing(DraftCatalogResource.WorkflowRequirementItem::name, TEXT_ORDER)
.thenComparing(DraftCatalogResource.WorkflowRequirementItem::id, ID_ORDER))
.flatMap(requirement -> requirement.roleIds().stream()
.map(roleId -> new RoleReference(roleId, findRole(catalog, roleId)))
.filter(role -> role.item() != null)
.sorted(Comparator.comparing((RoleReference role) -> role.item().name(), TEXT_ORDER)
.thenComparing(RoleReference::id, ID_ORDER))
.map(role -> projectRole(catalog, snapshot, workflow, requirement, role)))
.filter(java.util.Optional::isPresent)
.map(java.util.Optional::get)
.toList();
}

private java.util.Optional<DraftContinuityRiskResource> projectRole(
DraftCatalogResource catalog,
OrganizationSnapshot snapshot,
DraftCatalogResource.WorkflowItem workflow,
DraftCatalogResource.WorkflowRequirementItem requirement,
RoleReference role) {
List<DraftCatalogResource.MemberItem> holders = catalog.members().stream()
.filter(member -> member.roleIds().contains(role.id()))
.sorted(Comparator.comparing(DraftCatalogResource.MemberItem::name, TEXT_ORDER)
.thenComparing(DraftCatalogResource.MemberItem::id, ID_ORDER))
.toList();
if (holders.isEmpty()) return java.util.Optional.empty();

List<Scenario> scenarios = holders.stream()
.map(holder -> scenarioFor(snapshot, workflow.id(), requirement.id(), holder, role.id()))
.toList();
ImpactResult.StepImpact baseline = scenarios.getFirst().step();
return java.util.Optional.of(new DraftContinuityRiskResource(
workflow.id() + ":" + requirement.id() + ":" + role.id(),
workflow.id(),
workflow.name(),
WorkflowCriticality.valueOf(workflow.criticality()),
requirement.id(),
requirement.name(),
requirement.minimumActors(),
requirement.resilienceTarget(),
role.id(),
role.item().name(),
baseline.baselineEligibleActors().stream()
.sorted(Comparator.comparing(ImpactResult.ActorRef::name, TEXT_ORDER)
.thenComparing(ImpactResult.ActorRef::id, ID_ORDER))
.map(actor -> new DraftContinuityRiskResource.EligibleMember(actor.id(), actor.name()))
.toList(),
scenarios.stream().map(Scenario::toMemberScenario).toList()));
}

private Scenario scenarioFor(
OrganizationSnapshot snapshot,
UUID workflowId,
UUID stepId,
DraftCatalogResource.MemberItem holder,
UUID roleId) {
ImpactResult result = impactEngine.analyze(snapshot, new RevokeEmployeeRole(holder.id(), roleId));
ImpactResult.WorkflowImpact workflow = result.workflowImpacts().stream()
.filter(candidate -> candidate.workflowId().equals(workflowId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Impact result did not include workflow " + workflowId));
ImpactResult.StepImpact step = workflow.steps().stream()
.filter(candidate -> candidate.stepId().equals(stepId))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Impact result did not include workflow step " + stepId));
return new Scenario(holder, workflow.scenarioStatus(), step);
}

private DraftCatalogResource.RoleItem findRole(DraftCatalogResource catalog, UUID roleId) {
return catalog.roles().stream().filter(role -> role.id().equals(roleId)).findFirst().orElse(null);
}

private void validateDraftWorkspace(UUID workspaceId) {
String status = catalogRepository.findWorkspaceStatus(workspaceId)
.orElseThrow(() -> new WorkspaceNotFoundException(workspaceId));
if (!"DRAFT".equals(status)) throw new PublishedWorkspaceMutationException();
}

private record RoleReference(UUID id, DraftCatalogResource.RoleItem item) {
}

private record Scenario(
DraftCatalogResource.MemberItem holder,
ImpactResult.WorkflowStatus workflowStatus,
ImpactResult.StepImpact step) {

DraftContinuityRiskResource.MemberScenario toMemberScenario() {
boolean eligible = step.baselineEligibleActors().stream().anyMatch(actor -> actor.id().equals(holder.id()));
boolean losesCoverage = eligible && step.scenarioEligibleActors().stream()
.noneMatch(actor -> actor.id().equals(holder.id()));
return new DraftContinuityRiskResource.MemberScenario(
holder.id(), holder.name(), eligible, losesCoverage,
step.scenarioEligibleActors().size(), workflowStatus);
}
}
}
Loading
Loading