diff --git a/README.md b/README.md index 4a80115..50ec3df 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/TvkQWWs6) # Features of modern Java # Цели и задачи л/р: -На основе индивидуального задания произвести разработку бизнес-логики бэкэнда entriprise-системы. - В ходе реализации необходимо использовать возможности современных версий языка Java: -* Pattern matching для switch -* строковые шаблоны)))))))))))))) +* Pattern matching для switch - ProjectService.getRoleDescription() * расширенные возможности стандартной библиотеки Java -* sealed классы и record -* программирование в функциональном стиле -* preview как project Valhalla, structured concurrency... +* sealed классы и record - sealed interface ProjectRole, sealed class ProjectManagementException +* программирование в функциональном стиле - использование stream api / lambdas +* preview как project Valhalla, structured concurrency... - Использование structured concurrency в ProjectSummaryService * и т.д. # Обязательное условие: diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..4cb5d46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,21 @@ plugins { id("java") + id("application") +} + +application { + mainClass.set("org.lab.Demo") } group = "org.lab" version = "1.0-SNAPSHOT" +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + repositories { mavenCentral() } @@ -13,8 +24,18 @@ dependencies { testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("com.google.guava:guava:33.5.0-jre") +} + +tasks.withType { + options.compilerArgs.addAll(listOf("--enable-preview")) +} + +tasks.withType { + jvmArgs("--enable-preview") } tasks.test { useJUnitPlatform() -} \ No newline at end of file + jvmArgs("--enable-preview") +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/org/lab/Demo.java b/src/main/java/org/lab/Demo.java new file mode 100644 index 0000000..a2289c1 --- /dev/null +++ b/src/main/java/org/lab/Demo.java @@ -0,0 +1,176 @@ +package org.lab; + +import java.time.LocalDate; +import java.util.concurrent.ExecutionException; + +import org.lab.model.BugReport; +import org.lab.model.BugSeverity; +import org.lab.model.Milestone; +import org.lab.model.Project; +import org.lab.model.Ticket; +import org.lab.model.User; +import org.lab.service.BugReportService; +import org.lab.service.MilestoneService; +import org.lab.service.ProjectService; +import org.lab.service.ProjectSummaryService; +import org.lab.service.TicketService; +import org.lab.service.UserService; + +public class Demo { + + public static void main(String[] args) throws InterruptedException, ExecutionException { + var system = ProjectManagementSystem.withDefault(); + new Demo().run(system); + } + + public void run(ProjectManagementSystem system) throws InterruptedException, ExecutionException { + UserService userService = system.userService(); + ProjectService projectService = system.projectService(); + MilestoneService milestoneService = system.milestoneService(); + TicketService ticketService = system.ticketService(); + BugReportService bugReportService = system.bugReportService(); + ProjectSummaryService summaryService = system.projectSummaryService(); + + System.out.println("=== Registering users ==="); + User manager = userService.register("alice", "alice@company.com", "Alice Johnson"); + User teamLead = userService.register("bob", "bob@company.com", "Bob Smith"); + User dev1 = userService.register("charlie", "charlie@company.com", "Charlie Brown"); + User dev2 = userService.register("diana", "diana@company.com", "Diana Prince"); + User tester = userService.register("eve", "eve@company.com", "Eve Williams"); + + for (User user : userService.findAll()) { + System.out.println(user); + } + + System.out.println("\n=== Creating project ==="); + Project project = projectService.createProject( + "E-Commerce Platform", + "Modern e-commerce with microservices", + manager.id() + ); + project = projectService.assignTeamLead(project.id(), manager.id(), teamLead.id()); + project = projectService.addDeveloper(project.id(), manager.id(), dev1.id()); + project = projectService.addDeveloper(project.id(), manager.id(), dev2.id()); + project = projectService.addTester(project.id(), manager.id(), tester.id()); + System.out.println(project); + + System.out.println("\nProject team:"); + for (var info : projectService.getProjectTeam(project.id())) { + System.out.println(STR." \{info.role().roleName()}: \{info.user().fullName()}, \{info.description()}"); + } + + System.out.println("\n=== Creating and activating milestone ==="); + Milestone sprint = milestoneService.createMilestone( + project.id(), manager.id(), + "Sprint 1", "MVP features", + LocalDate.now(), LocalDate.now().plusWeeks(2) + ); + sprint = milestoneService.activate(sprint.id(), manager.id()); + System.out.println(STR."Milestone: \{sprint.name()} [\{sprint.status()}]"); + System.out.println(STR."Period: \{sprint.startDate()} - \{sprint.endDate()}"); + + System.out.println("\n=== Creating and assigning tickets ==="); + Ticket t1 = ticketService.createTicket( + project.id(), + sprint.id(), + teamLead.id(), + "Product Catalog API", + "REST endpoints for products" + ); + Ticket t2 = ticketService.createTicket( + project.id(), + sprint.id(), + teamLead.id(), + "Shopping Cart", + "Cart functionality" + ); + Ticket t3 = ticketService.createTicket( + project.id(), + sprint.id(), + teamLead.id(), + "User Auth", + "JWT authentication" + ); + + t1 = ticketService.assignDeveloper(t1.id(), teamLead.id(), dev1.id()); + t2 = ticketService.assignDeveloper(t2.id(), teamLead.id(), dev2.id()); + t3 = ticketService.assignDeveloper(t3.id(), teamLead.id(), dev1.id()); + + System.out.println(ticketService.getProjectStats(project.id()).summary()); + + System.out.println("=== Developing ==="); + t1 = ticketService.acceptTicket(t1.id(), dev1.id()); + t1 = ticketService.startProgress(t1.id(), dev1.id()); + t1 = ticketService.completeTicket(t1.id(), dev1.id()); + System.out.println(STR."\{dev1.fullName()} completed '\{t1.title()}'"); + + t2 = ticketService.acceptTicket(t2.id(), dev2.id()); + t2 = ticketService.startProgress(t2.id(), dev2.id()); + t2 = ticketService.completeTicket(t2.id(), dev2.id()); + System.out.println(STR."\{dev2.fullName()} completed '\{t2.title()}'"); + + System.out.println("\n=== Code review ==="); + var review1 = ticketService.reviewTicket(t1.id(), teamLead.id(), true); + System.out.println(STR."'\{t1.title()}': \{review1.message()}"); + + var review2 = ticketService.reviewTicket(t2.id(), teamLead.id(), false); + System.out.println(STR."'\{t2.title()}': \{review2.message()} -> status: \{review2.ticket().status()}"); + + t2 = ticketService.completeTicket(review2.ticket().id(), dev2.id()); + review2 = ticketService.reviewTicket(t2.id(), teamLead.id(), true); + System.out.println(STR."'\{t2.title()}' after rework: \{review2.message()}"); + + System.out.println("\n=== Bug reports ==="); + BugReport bug1 = bugReportService.createBugReport( + project.id(), + tester.id(), + "Cart calculation error", + "Discount not applied", + BugSeverity.CRITICAL + ); + BugReport bug2 = bugReportService.createBugReport( + project.id(), + tester.id(), + "Image not loading", + "404 on thumbnails", + BugSeverity.MAJOR + ); + + System.out.println(bugReportService.getProjectStats(project.id()).summary()); + + System.out.println("=== Bug lifecycle ==="); + bug1 = bugReportService.assignDeveloper(bug1.id(), teamLead.id(), dev2.id()); + bug2 = bugReportService.assignDeveloper(bug2.id(), teamLead.id(), dev1.id()); + + bug1 = bugReportService.markAsFixed(bug1.id(), dev2.id()); + bug2 = bugReportService.markAsFixed(bug2.id(), dev1.id()); + System.out.println(STR."Bugs fixed: \{bug1.title()}, \{bug2.title()}"); + + bug1 = bugReportService.verifyFix(bug1.id(), tester.id(), true); + bug2 = bugReportService.verifyFix(bug2.id(), tester.id(), true); + System.out.println("Bugs verified by tester"); + + bugReportService.close(bug1.id(), manager.id()); + bugReportService.close(bug2.id(), manager.id()); + + System.out.println(bugReportService.getProjectStats(project.id()).summary()); + + System.out.println("=== Completing sprint ==="); + t3 = ticketService.acceptTicket(t3.id(), dev1.id()); + t3 = ticketService.startProgress(t3.id(), dev1.id()); + t3 = ticketService.completeTicket(t3.id(), dev1.id()); + ticketService.reviewTicket(t3.id(), teamLead.id(), true); + + System.out.println(milestoneService.getStats(sprint.id()).summary()); + + sprint = milestoneService.close(sprint.id(), manager.id()); + System.out.println(STR."Milestone '\{sprint.name()}' closed: \{sprint.status()}"); + + var summary = summaryService.getProjectSummary(project.id()); + System.out.println(summary.generateReport()); + + var activity = summaryService.getUserActivitySummary(dev1.id()); + System.out.println(STR."Activity for \{dev1.fullName()}:"); + System.out.println(activity.format()); + } +} diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index 22028ef..4e94411 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -1,4 +1,8 @@ -void main() { - IO.println("Hello and welcome!"); -} +package org.lab; +public class Main { + + public static void main(String[] args) { + ProjectManagementSystem system = ProjectManagementSystem.withDefault(); + } +} diff --git a/src/main/java/org/lab/ProjectManagementSystem.java b/src/main/java/org/lab/ProjectManagementSystem.java new file mode 100644 index 0000000..2cb16e6 --- /dev/null +++ b/src/main/java/org/lab/ProjectManagementSystem.java @@ -0,0 +1,42 @@ +package org.lab; + +import org.lab.repository.*; +import org.lab.service.*; + +public record ProjectManagementSystem( + UserService userService, + ProjectService projectService, + MilestoneService milestoneService, + TicketService ticketService, + BugReportService bugReportService, + ProjectSummaryService projectSummaryService +) { + public static ProjectManagementSystem withDefault() { + var userRepository = new UserRepository(); + var projectRepository = new ProjectRepository(); + var milestoneRepository = new MilestoneRepository(); + var ticketRepository = new TicketRepository(); + var bugReportRepository = new BugReportRepository(); + + var userService = new UserService(userRepository); + var projectService = new ProjectService(projectRepository, userService); + var milestoneService = new MilestoneService(milestoneRepository, ticketRepository, projectService); + var ticketService = new TicketService(ticketRepository, milestoneRepository, projectService); + var bugReportService = new BugReportService(bugReportRepository, projectService); + var projectSummaryService = new ProjectSummaryService( + projectService, + ticketRepository, + bugReportRepository, + milestoneRepository + ); + + return new ProjectManagementSystem( + userService, + projectService, + milestoneService, + ticketService, + bugReportService, + projectSummaryService + ); + } +} diff --git a/src/main/java/org/lab/exception/ProjectManagementException.java b/src/main/java/org/lab/exception/ProjectManagementException.java new file mode 100644 index 0000000..ab60c5e --- /dev/null +++ b/src/main/java/org/lab/exception/ProjectManagementException.java @@ -0,0 +1,73 @@ +package org.lab.exception; + +import org.lab.model.role.Permission; + +public sealed class ProjectManagementException extends RuntimeException permits + ProjectManagementException.EntityNotFoundException, + ProjectManagementException.AccessDeniedException, + ProjectManagementException.InvalidOperationException, + ProjectManagementException.DuplicateEntityException, + ProjectManagementException.ValidationException { + + public ProjectManagementException(String message) { + super(message); + } + + public static final class EntityNotFoundException extends ProjectManagementException { + private final String entityType; + private final String identifier; + + public EntityNotFoundException(String entityType, String identifier) { + super("%s not found: %s".formatted(entityType, identifier)); + this.entityType = entityType; + this.identifier = identifier; + } + + public String getEntityType() { + return entityType; + } + public String getIdentifier() { + return identifier; + } + } + + public static final class AccessDeniedException extends ProjectManagementException { + public AccessDeniedException(Permission requiredPermission) { + super("Access denied. Required permission: %s".formatted(requiredPermission)); + } + + public AccessDeniedException(String message) { + super(message); + } + } + + public static final class InvalidOperationException extends ProjectManagementException { + public InvalidOperationException(String message) { + super(message); + } + } + + public static final class DuplicateEntityException extends ProjectManagementException { + private final String entityType; + private final String field; + + public DuplicateEntityException(String entityType, String field, String value) { + super("%s with %s='%s' already exists".formatted(entityType, field, value)); + this.entityType = entityType; + this.field = field; + } + + public String getEntityType() { + return entityType; + } + public String getField() { + return field; + } + } + + public static final class ValidationException extends ProjectManagementException { + public ValidationException(String message) { + super(message); + } + } +} diff --git a/src/main/java/org/lab/model/BugReport.java b/src/main/java/org/lab/model/BugReport.java new file mode 100644 index 0000000..8b58f00 --- /dev/null +++ b/src/main/java/org/lab/model/BugReport.java @@ -0,0 +1,93 @@ +package org.lab.model; + +import org.lab.model.status.BugReportStatus; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +public record BugReport( + UUID id, + UUID projectId, + String title, + String description, + BugReportStatus status, + UUID reportedBy, + UUID assignedTo, + LocalDateTime createdAt, + BugSeverity severity +) implements Entity { + public static final String ENTITY_NAME = "BugReport"; + + @Override + public String entityName() { + return ENTITY_NAME; + } + + public BugReport { + Objects.requireNonNull(title, "title must not be null"); + Objects.requireNonNull(projectId, "projectId must not be null"); + Objects.requireNonNull(reportedBy, "reportedBy must not be null"); + } + + public static BugReport of( + UUID projectId, + String title, + String description, + UUID reportedBy, + BugSeverity severity + ) { + return new BugReport( + UUID.randomUUID(), + projectId, + title, + description, + BugReportStatus.NEW, + reportedBy, + null, + LocalDateTime.now(), + severity + ); + } + + public BugReport withStatus(BugReportStatus newStatus) { + return new BugReport( + id, + projectId, + title, + description, + newStatus, + reportedBy, + assignedTo, + createdAt, + severity + ); + } + + public BugReport withAssignedDeveloper(UUID developerId) { + return new BugReport( + id, + projectId, + title, + description, + status, + reportedBy, + developerId, + createdAt, + severity + ); + } + + public boolean isClosed() { + return status == BugReportStatus.CLOSED; + } + + public String getPriorityDescription() { + return switch (severity) { + case CRITICAL -> "Requires immediate fix"; + case MAJOR -> "Must fix in current sprint"; + case MINOR -> "Can be postponed to next sprint"; + case TRIVIAL -> "Low priority"; + }; + } +} diff --git a/src/main/java/org/lab/model/BugSeverity.java b/src/main/java/org/lab/model/BugSeverity.java new file mode 100644 index 0000000..8d1403a --- /dev/null +++ b/src/main/java/org/lab/model/BugSeverity.java @@ -0,0 +1,24 @@ +package org.lab.model; + +public enum BugSeverity { + CRITICAL("Critical", 1), + MAJOR("Major", 2), + MINOR("Minor", 3), + TRIVIAL("Trivial", 4); + + private final String displayName; + private final int priority; + + BugSeverity(String displayName, int priority) { + this.displayName = displayName; + this.priority = priority; + } + + public String getDisplayName() { + return displayName; + } + + public int getPriority() { + return priority; + } +} diff --git a/src/main/java/org/lab/model/Entity.java b/src/main/java/org/lab/model/Entity.java new file mode 100644 index 0000000..9503ec7 --- /dev/null +++ b/src/main/java/org/lab/model/Entity.java @@ -0,0 +1,8 @@ +package org.lab.model; + +import java.util.UUID; + +public interface Entity { + UUID id(); + String entityName(); +} diff --git a/src/main/java/org/lab/model/Milestone.java b/src/main/java/org/lab/model/Milestone.java new file mode 100644 index 0000000..12ee113 --- /dev/null +++ b/src/main/java/org/lab/model/Milestone.java @@ -0,0 +1,65 @@ +package org.lab.model; + +import org.lab.model.status.MilestoneStatus; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +public record Milestone( + UUID id, + UUID projectId, + String name, + String description, + LocalDate startDate, + LocalDate endDate, + MilestoneStatus status +) implements Entity { + public static final String ENTITY_NAME = "Milestone"; + + @Override + public String entityName() { + return ENTITY_NAME; + } + + public Milestone { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(projectId, "projectId must not be null"); + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("startDate must not be after endDate"); + } + } + + public static Milestone of( + UUID projectId, + String name, + String description, + LocalDate startDate, + LocalDate endDate + ) { + return new Milestone( + UUID.randomUUID(), + projectId, + name, + description, + startDate, + endDate, + MilestoneStatus.OPEN + ); + } + + public Milestone withStatus(MilestoneStatus newStatus) { + return new Milestone(id, projectId, name, description, startDate, endDate, newStatus); + } + + public boolean isActiveOn(LocalDate date) { + if (startDate == null || endDate == null) { + return false; + } + return !date.isBefore(startDate) && !date.isAfter(endDate); + } + + public boolean canBeClosed() { + return status == MilestoneStatus.ACTIVE; + } +} diff --git a/src/main/java/org/lab/model/Project.java b/src/main/java/org/lab/model/Project.java new file mode 100644 index 0000000..48f4631 --- /dev/null +++ b/src/main/java/org/lab/model/Project.java @@ -0,0 +1,144 @@ +package org.lab.model; + +import com.google.common.base.MoreObjects; +import org.lab.model.role.*; + +import java.time.LocalDateTime; +import java.util.*; + +public record Project( + UUID id, + String name, + String description, + UUID managerId, + UUID teamLeadId, + Set developerIds, + Set testerIds, + LocalDateTime createdAt +) implements Entity { + public static final String ENTITY_NAME = "Project"; + + @Override + public String entityName() { + return ENTITY_NAME; + } + + public Project { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(managerId, "managerId must not be null"); + developerIds = developerIds == null + ? Set.of() + : Set.copyOf(developerIds); + testerIds = testerIds == null + ? Set.of() + : Set.copyOf(testerIds); + } + + public static Project of(String name, String description, UUID managerId) { + return new Project( + UUID.randomUUID(), + name, + description, + managerId, + null, + Set.of(), + Set.of(), + LocalDateTime.now() + ); + } + + public Project withTeamLead(UUID teamLeadId) { + return new Project( + id, + name, + description, + managerId, + teamLeadId, + developerIds, + testerIds, + createdAt + ); + } + + public Project withDeveloper(UUID developerId) { + Set newDevelopers = new HashSet<>(developerIds); + newDevelopers.add(developerId); + return new Project( + id, + name, + description, + managerId, + teamLeadId, + newDevelopers, + testerIds, + createdAt + ); + } + + public Project withTester(UUID testerId) { + Set newTesters = new HashSet<>(testerIds); + newTesters.add(testerId); + return new Project( + id, + name, + description, + managerId, + teamLeadId, + developerIds, + newTesters, + createdAt + ); + } + + public boolean isMember(UUID userId) { + return managerId.equals(userId) + || (teamLeadId != null && teamLeadId.equals(userId)) + || developerIds.contains(userId) + || testerIds.contains(userId); + } + + public Optional getRoleFor(UUID userId) { + if (managerId.equals(userId)) { + return Optional.of(new Manager(userId, id)); + } + if (teamLeadId != null && teamLeadId.equals(userId)) { + return Optional.of(new TeamLead(userId, id)); + } + if (developerIds.contains(userId)) { + return Optional.of(new Developer(userId, id)); + } + if (testerIds.contains(userId)) { + return Optional.of(new Tester(userId, id)); + } + return Optional.empty(); + } + + public Set getAllMemberIds() { + Set members = new HashSet<>(); + members.add(managerId); + if (teamLeadId != null) { + members.add(teamLeadId); + } + members.addAll(developerIds); + members.addAll(testerIds); + return Collections.unmodifiableSet(members); + } + + public int getMemberCount() { + return getAllMemberIds().size(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("name", name) + .add("description", description) + .add("managerId", managerId) + .add("teamLeadId", teamLeadId) + .add("developerIds", developerIds) + .add("testerIds", testerIds) + .add("createdAt", createdAt) + .toString(); + } +} diff --git a/src/main/java/org/lab/model/Ticket.java b/src/main/java/org/lab/model/Ticket.java new file mode 100644 index 0000000..f2a3a0b --- /dev/null +++ b/src/main/java/org/lab/model/Ticket.java @@ -0,0 +1,95 @@ +package org.lab.model; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import org.lab.model.status.TicketStatus; + +public record Ticket( + UUID id, + UUID projectId, + UUID milestoneId, + String title, + String description, + TicketStatus status, + Set assignedDeveloperIds, + LocalDateTime createdAt, + UUID createdBy +) implements Entity { + public static final String ENTITY_NAME = "Ticket"; + + @Override + public String entityName() { + return ENTITY_NAME; + } + + public Ticket { + Objects.requireNonNull(title, "title must not be null"); + Objects.requireNonNull(projectId, "projectId must not be null"); + Objects.requireNonNull(milestoneId, "milestoneId must not be null"); + assignedDeveloperIds = assignedDeveloperIds == null + ? Set.of() + : Set.copyOf(assignedDeveloperIds); + } + + public static Ticket of( + UUID projectId, + UUID milestoneId, + String title, + String description, + UUID createdBy + ) { + return new Ticket( + UUID.randomUUID(), + projectId, + milestoneId, + title, + description, + TicketStatus.NEW, + Set.of(), + LocalDateTime.now(), + createdBy + ); + } + + public Ticket withStatus(TicketStatus newStatus) { + return new Ticket( + id, + projectId, + milestoneId, + title, + description, + newStatus, + assignedDeveloperIds, + createdAt, + createdBy + ); + } + + public Ticket withAssignedDeveloper(UUID developerId) { + Set newAssignees = new HashSet<>(assignedDeveloperIds); + newAssignees.add(developerId); + return new Ticket( + id, + projectId, + milestoneId, + title, + description, + status, + newAssignees, + createdAt, + createdBy + ); + } + + public boolean isAssignedTo(UUID developerId) { + return assignedDeveloperIds.contains(developerId); + } + + public boolean isDone() { + return status == TicketStatus.DONE; + } +} diff --git a/src/main/java/org/lab/model/User.java b/src/main/java/org/lab/model/User.java new file mode 100644 index 0000000..3fcbdd3 --- /dev/null +++ b/src/main/java/org/lab/model/User.java @@ -0,0 +1,43 @@ +package org.lab.model; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +import com.google.common.base.MoreObjects; + +public record User( + UUID id, + String username, + String email, + String fullName, + LocalDateTime registeredAt +) implements Entity { + public static final String ENTITY_NAME = "User"; + + @Override + public String entityName() { + return ENTITY_NAME; + } + + public User { + Objects.requireNonNull(username, "username must not be null"); + Objects.requireNonNull(email, "email must not be null"); + Objects.requireNonNull(fullName, "fullName must not be null"); + } + + public static User of(String username, String email, String fullName) { + return new User(UUID.randomUUID(), username, email, fullName, LocalDateTime.now()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("username", username) + .add("fullName", fullName) + .add("email", email) + .add("registeredAt", registeredAt) + .toString(); + } +} diff --git a/src/main/java/org/lab/model/role/Developer.java b/src/main/java/org/lab/model/role/Developer.java new file mode 100644 index 0000000..e60e3e1 --- /dev/null +++ b/src/main/java/org/lab/model/role/Developer.java @@ -0,0 +1,22 @@ +package org.lab.model.role; + +import java.util.Set; +import java.util.UUID; + +public record Developer(UUID userId, UUID projectId) implements ProjectRole { + private static final Set PERMISSIONS = Set.of( + Permission.EXECUTE_TICKETS, + Permission.CREATE_BUG_REPORTS, + Permission.FIX_BUGS + ); + + @Override + public String roleName() { + return "Developer"; + } + + @Override + public boolean hasPermission(Permission permission) { + return PERMISSIONS.contains(permission); + } +} diff --git a/src/main/java/org/lab/model/role/Manager.java b/src/main/java/org/lab/model/role/Manager.java new file mode 100644 index 0000000..341a287 --- /dev/null +++ b/src/main/java/org/lab/model/role/Manager.java @@ -0,0 +1,24 @@ +package org.lab.model.role; + +import java.util.Set; +import java.util.UUID; + +public record Manager(UUID userId, UUID projectId) implements ProjectRole { + private static final Set PERMISSIONS = Set.of( + Permission.MANAGE_USERS, + Permission.CREATE_TICKETS, + Permission.MANAGE_MILESTONES, + Permission.REVIEW_TICKETS, + Permission.CLOSE_BUGS + ); + + @Override + public String roleName() { + return "Manager"; + } + + @Override + public boolean hasPermission(Permission permission) { + return PERMISSIONS.contains(permission); + } +} diff --git a/src/main/java/org/lab/model/role/Permission.java b/src/main/java/org/lab/model/role/Permission.java new file mode 100644 index 0000000..4cef817 --- /dev/null +++ b/src/main/java/org/lab/model/role/Permission.java @@ -0,0 +1,14 @@ +package org.lab.model.role; + +public enum Permission { + MANAGE_USERS, + CREATE_TICKETS, + EXECUTE_TICKETS, + CREATE_BUG_REPORTS, + FIX_BUGS, + TEST_BUG_FIXES, + MANAGE_MILESTONES, + REVIEW_TICKETS, + CLOSE_BUGS +} + diff --git a/src/main/java/org/lab/model/role/ProjectRole.java b/src/main/java/org/lab/model/role/ProjectRole.java new file mode 100644 index 0000000..75ad5ff --- /dev/null +++ b/src/main/java/org/lab/model/role/ProjectRole.java @@ -0,0 +1,22 @@ +package org.lab.model.role; + +import org.lab.exception.ProjectManagementException.AccessDeniedException; + +import java.util.UUID; + +public sealed interface ProjectRole permits Manager, TeamLead, Developer, Tester { + + UUID userId(); + + UUID projectId(); + + String roleName(); + + boolean hasPermission(Permission permission); + + default void require(Permission permission) { + if (!hasPermission(permission)) { + throw new AccessDeniedException(permission); + } + } +} diff --git a/src/main/java/org/lab/model/role/TeamLead.java b/src/main/java/org/lab/model/role/TeamLead.java new file mode 100644 index 0000000..dc7a97c --- /dev/null +++ b/src/main/java/org/lab/model/role/TeamLead.java @@ -0,0 +1,23 @@ +package org.lab.model.role; + +import java.util.Set; +import java.util.UUID; + +public record TeamLead(UUID userId, UUID projectId) implements ProjectRole { + private static final Set PERMISSIONS = Set.of( + Permission.CREATE_TICKETS, + Permission.EXECUTE_TICKETS, + Permission.REVIEW_TICKETS, + Permission.CLOSE_BUGS + ); + + @Override + public String roleName() { + return "Team Lead"; + } + + @Override + public boolean hasPermission(Permission permission) { + return PERMISSIONS.contains(permission); + } +} diff --git a/src/main/java/org/lab/model/role/Tester.java b/src/main/java/org/lab/model/role/Tester.java new file mode 100644 index 0000000..2db30a1 --- /dev/null +++ b/src/main/java/org/lab/model/role/Tester.java @@ -0,0 +1,22 @@ +package org.lab.model.role; + +import java.util.Set; +import java.util.UUID; + +public record Tester(UUID userId, UUID projectId) implements ProjectRole { + private static final Set PERMISSIONS = Set.of( + Permission.CREATE_BUG_REPORTS, + Permission.TEST_BUG_FIXES, + Permission.CLOSE_BUGS + ); + + @Override + public String roleName() { + return "Tester"; + } + + @Override + public boolean hasPermission(Permission permission) { + return PERMISSIONS.contains(permission); + } +} diff --git a/src/main/java/org/lab/model/status/BugReportStatus.java b/src/main/java/org/lab/model/status/BugReportStatus.java new file mode 100644 index 0000000..68d3495 --- /dev/null +++ b/src/main/java/org/lab/model/status/BugReportStatus.java @@ -0,0 +1,27 @@ +package org.lab.model.status; + +public enum BugReportStatus { + NEW("New"), + FIXED("Fixed"), + TESTED("Tested"), + CLOSED("Closed"); + + private final String displayName; + + BugReportStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public boolean canTransitionTo(BugReportStatus target) { + return switch (this) { + case NEW -> target == FIXED; + case FIXED -> target == TESTED || target == NEW; + case TESTED -> target == CLOSED || target == NEW; + case CLOSED -> false; + }; + } +} diff --git a/src/main/java/org/lab/model/status/MilestoneStatus.java b/src/main/java/org/lab/model/status/MilestoneStatus.java new file mode 100644 index 0000000..c31fccb --- /dev/null +++ b/src/main/java/org/lab/model/status/MilestoneStatus.java @@ -0,0 +1,17 @@ +package org.lab.model.status; + +public enum MilestoneStatus { + OPEN("Open"), + ACTIVE("Active"), + CLOSED("Closed"); + + private final String displayName; + + MilestoneStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/org/lab/model/status/TicketStatus.java b/src/main/java/org/lab/model/status/TicketStatus.java new file mode 100644 index 0000000..7b0d373 --- /dev/null +++ b/src/main/java/org/lab/model/status/TicketStatus.java @@ -0,0 +1,27 @@ +package org.lab.model.status; + +public enum TicketStatus { + NEW("New"), + ACCEPTED("Accepted"), + IN_PROGRESS("In Progress"), + DONE("Done"); + + private final String displayName; + + TicketStatus(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public boolean canTransitionTo(TicketStatus target) { + return switch (this) { + case NEW -> target == ACCEPTED; + case ACCEPTED -> target == IN_PROGRESS || target == NEW; + case IN_PROGRESS -> target == DONE || target == ACCEPTED; + case DONE -> false; + }; + } +} diff --git a/src/main/java/org/lab/repository/BugReportRepository.java b/src/main/java/org/lab/repository/BugReportRepository.java new file mode 100644 index 0000000..0785125 --- /dev/null +++ b/src/main/java/org/lab/repository/BugReportRepository.java @@ -0,0 +1,66 @@ +package org.lab.repository; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.lab.model.BugReport; +import org.lab.model.status.BugReportStatus; + +public class BugReportRepository extends InMemoryRepository { + + @Override + protected String getEntityName() { + return BugReport.ENTITY_NAME; + } + + public List findByProjectId(UUID projectId) { + return findBy(report -> report.projectId().equals(projectId)); + } + + public List findByAssigneeId(UUID developerId) { + return findBy(report -> + report.assignedTo() != null + && report.assignedTo().equals(developerId) + ); + } + + public List findByReporterId(UUID userId) { + return findBy(report -> report.reportedBy().equals(userId)); + } + + public List findByProjectIdAndStatus(UUID projectId, BugReportStatus status) { + return findBy(report -> + report.projectId().equals(projectId) + && report.status() == status + ); + } + + public List findOpenByAssigneeId(UUID developerId) { + return findBy(report -> + report.assignedTo() != null && + report.assignedTo().equals(developerId) && + !report.isClosed() + ); + } + + public List findReadyForTestingByProjectId(UUID projectId) { + return findByProjectIdAndStatus(projectId, BugReportStatus.FIXED); + } + + public List findByProjectIdSortedByPriority(UUID projectId) { + return findByProjectId(projectId).stream() + .sorted(Comparator.comparingInt(report -> report.severity().getPriority())) + .toList(); + } + + public Map countByStatusForProject(UUID projectId) { + return findByProjectId(projectId).stream() + .collect(Collectors.groupingBy( + BugReport::status, + Collectors.counting() + )); + } +} diff --git a/src/main/java/org/lab/repository/InMemoryRepository.java b/src/main/java/org/lab/repository/InMemoryRepository.java new file mode 100644 index 0000000..2f5c38a --- /dev/null +++ b/src/main/java/org/lab/repository/InMemoryRepository.java @@ -0,0 +1,61 @@ +package org.lab.repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +import org.lab.exception.ProjectManagementException.EntityNotFoundException; +import org.lab.model.Entity; + +public abstract class InMemoryRepository implements Repository { + + protected final Map storage = new ConcurrentHashMap<>(); + + protected abstract String getEntityName(); + + @Override + public T save(T entity) { + storage.put(entity.id(), entity); + return entity; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + public T getById(UUID id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException(getEntityName(), id.toString())); + } + + @Override + public List findAll() { + return List.copyOf(storage.values()); + } + + @Override + public List findBy(Predicate predicate) { + return storage.values().stream() + .filter(predicate) + .toList(); + } + + @Override + public boolean deleteById(UUID id) { + return storage.remove(id) != null; + } + + @Override + public boolean existsById(UUID id) { + return storage.containsKey(id); + } + + @Override + public long count() { + return storage.size(); + } +} diff --git a/src/main/java/org/lab/repository/MilestoneRepository.java b/src/main/java/org/lab/repository/MilestoneRepository.java new file mode 100644 index 0000000..c890a2b --- /dev/null +++ b/src/main/java/org/lab/repository/MilestoneRepository.java @@ -0,0 +1,32 @@ +package org.lab.repository; + +import org.lab.model.Milestone; +import org.lab.model.status.MilestoneStatus; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class MilestoneRepository extends InMemoryRepository { + + @Override + protected String getEntityName() { + return Milestone.ENTITY_NAME; + } + + public List findByProjectId(UUID projectId) { + return findBy(milestone -> milestone.projectId().equals(projectId)); + } + + public Optional findActiveByProjectId(UUID projectId) { + return storage.values().stream() + .filter(milestone -> + milestone.projectId().equals(projectId) && + milestone.status() == MilestoneStatus.ACTIVE) + .findFirst(); + } + + public boolean hasActiveMilestone(UUID projectId) { + return findActiveByProjectId(projectId).isPresent(); + } +} diff --git a/src/main/java/org/lab/repository/ProjectRepository.java b/src/main/java/org/lab/repository/ProjectRepository.java new file mode 100644 index 0000000..0dbe6b2 --- /dev/null +++ b/src/main/java/org/lab/repository/ProjectRepository.java @@ -0,0 +1,20 @@ +package org.lab.repository; + +import org.lab.model.Project; + +import java.util.List; +import java.util.UUID; + +public class ProjectRepository extends InMemoryRepository { + + @Override + protected String getEntityName() { + return Project.ENTITY_NAME; + } + + public List findByMemberId(UUID userId) { + return storage.values().stream() + .filter(project -> project.isMember(userId)) + .toList(); + } +} diff --git a/src/main/java/org/lab/repository/Repository.java b/src/main/java/org/lab/repository/Repository.java new file mode 100644 index 0000000..370dce5 --- /dev/null +++ b/src/main/java/org/lab/repository/Repository.java @@ -0,0 +1,25 @@ +package org.lab.repository; + +import org.lab.model.Entity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Predicate; + +public interface Repository { + + T save(T entity); + + Optional findById(UUID id); + + List findAll(); + + List findBy(Predicate predicate); + + boolean deleteById(UUID id); + + boolean existsById(UUID id); + + long count(); +} diff --git a/src/main/java/org/lab/repository/TicketRepository.java b/src/main/java/org/lab/repository/TicketRepository.java new file mode 100644 index 0000000..dcbd8b7 --- /dev/null +++ b/src/main/java/org/lab/repository/TicketRepository.java @@ -0,0 +1,47 @@ +package org.lab.repository; + +import org.lab.model.Ticket; +import org.lab.model.status.TicketStatus; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +public class TicketRepository extends InMemoryRepository { + + @Override + protected String getEntityName() { + return Ticket.ENTITY_NAME; + } + + public List findByProjectId(UUID projectId) { + return findBy(ticket -> ticket.projectId().equals(projectId)); + } + + public List findByMilestoneId(UUID milestoneId) { + return findBy(ticket -> ticket.milestoneId().equals(milestoneId)); + } + + public List findByAssigneeId(UUID developerId) { + return findBy(ticket -> ticket.isAssignedTo(developerId)); + } + + public boolean areAllTicketsDone(UUID milestoneId) { + List tickets = findByMilestoneId(milestoneId); + return !tickets.isEmpty() && tickets.stream().allMatch(Ticket::isDone); + } + + public List findPendingByAssigneeId(UUID developerId) { + return findBy(ticket -> + ticket.isAssignedTo(developerId) && !ticket.isDone()); + } + + public Map countByStatusForProject(UUID projectId) { + return findByProjectId(projectId).stream() + .collect(Collectors.groupingBy( + Ticket::status, + Collectors.counting() + )); + } +} diff --git a/src/main/java/org/lab/repository/UserRepository.java b/src/main/java/org/lab/repository/UserRepository.java new file mode 100644 index 0000000..ea8a5fd --- /dev/null +++ b/src/main/java/org/lab/repository/UserRepository.java @@ -0,0 +1,33 @@ +package org.lab.repository; + +import org.lab.model.User; + +import java.util.Optional; + +public class UserRepository extends InMemoryRepository { + + @Override + protected String getEntityName() { + return User.ENTITY_NAME; + } + + public Optional findByUsername(String username) { + return storage.values().stream() + .filter(user -> user.username().equals(username)) + .findFirst(); + } + + public Optional findByEmail(String email) { + return storage.values().stream() + .filter(user -> user.email().equalsIgnoreCase(email)) + .findFirst(); + } + + public boolean existsByUsername(String username) { + return findByUsername(username).isPresent(); + } + + public boolean existsByEmail(String email) { + return findByEmail(email).isPresent(); + } +} diff --git a/src/main/java/org/lab/service/BugReportService.java b/src/main/java/org/lab/service/BugReportService.java new file mode 100644 index 0000000..c39569d --- /dev/null +++ b/src/main/java/org/lab/service/BugReportService.java @@ -0,0 +1,174 @@ +package org.lab.service; + +import java.util.List; +import java.util.UUID; + +import org.lab.exception.ProjectManagementException.AccessDeniedException; +import org.lab.exception.ProjectManagementException.InvalidOperationException; +import org.lab.model.BugReport; +import org.lab.model.BugSeverity; +import org.lab.model.role.Developer; +import org.lab.model.role.Permission; +import org.lab.model.role.ProjectRole; +import org.lab.model.status.BugReportStatus; +import org.lab.repository.BugReportRepository; + +public class BugReportService { + private final BugReportRepository bugReportRepository; + private final ProjectService projectService; + + public BugReportService( + BugReportRepository bugReportRepository, + ProjectService projectService + ) { + this.bugReportRepository = bugReportRepository; + this.projectService = projectService; + } + + public BugReport createBugReport( + UUID projectId, + UUID reporterId, + String title, + String description, + BugSeverity severity + ) { + ProjectRole role = projectService.getRole(projectId, reporterId); + role.require(Permission.CREATE_BUG_REPORTS); + + BugReport report = BugReport.of(projectId, title, description, reporterId, severity); + return bugReportRepository.save(report); + } + + public BugReport getById(UUID bugReportId) { + return bugReportRepository.getById(bugReportId); + } + + public List findByProjectId(UUID projectId) { + return bugReportRepository.findByProjectId(projectId); + } + + public List findByProjectSortedByPriority(UUID projectId) { + return bugReportRepository.findByProjectIdSortedByPriority(projectId); + } + + public List findAssignedToUser(UUID userId) { + return bugReportRepository.findOpenByAssigneeId(userId); + } + + public List findReadyForTesting(UUID projectId) { + return bugReportRepository.findReadyForTestingByProjectId(projectId); + } + + public BugReport assignDeveloper(UUID bugReportId, UUID assignerId, UUID developerId) { + BugReport report = getById(bugReportId); + ProjectRole assignerRole = projectService.getRole(report.projectId(), assignerId); + ProjectRole developerRole = projectService.getRole(report.projectId(), developerId); + + boolean canAssign = switch (assignerRole) { + case Developer _ -> assignerId.equals(developerId); + default -> assignerRole.hasPermission(Permission.REVIEW_TICKETS); + }; + + if (!canAssign) { + throw new AccessDeniedException("Cannot assign developer to bug"); + } + + if (!developerRole.hasPermission(Permission.FIX_BUGS)) { + throw new InvalidOperationException("User cannot fix bugs"); + } + + BugReport updated = report.withAssignedDeveloper(developerId); + return bugReportRepository.save(updated); + } + + public BugReport markAsFixed(UUID bugReportId, UUID developerId) { + BugReport report = getById(bugReportId); + ProjectRole role = projectService.getRole(report.projectId(), developerId); + + role.require(Permission.FIX_BUGS); + + if (report.assignedTo() == null || !report.assignedTo().equals(developerId)) { + throw new AccessDeniedException("You are not assigned to fix this bug"); + } + + if (report.status() != BugReportStatus.NEW) { + throw new InvalidOperationException("Can only fix bugs in 'New' status"); + } + + BugReport updated = report.withStatus(BugReportStatus.FIXED); + return bugReportRepository.save(updated); + } + + public BugReport verifyFix(UUID bugReportId, UUID testerId, boolean passed) { + BugReport report = getById(bugReportId); + ProjectRole role = projectService.getRole(report.projectId(), testerId); + + role.require(Permission.TEST_BUG_FIXES); + + if (report.status() != BugReportStatus.FIXED) { + throw new InvalidOperationException("Can only verify fixed bugs"); + } + + BugReportStatus newStatus = passed ? BugReportStatus.TESTED : BugReportStatus.NEW; + BugReport updated = report.withStatus(newStatus); + return bugReportRepository.save(updated); + } + + public BugReport close(UUID bugReportId, UUID userId) { + BugReport report = getById(bugReportId); + ProjectRole role = projectService.getRole(report.projectId(), userId); + + role.require(Permission.CLOSE_BUGS); + + if (report.status() != BugReportStatus.TESTED) { + throw new InvalidOperationException("Can only close tested bugs"); + } + + BugReport updated = report.withStatus(BugReportStatus.CLOSED); + return bugReportRepository.save(updated); + } + + public BugReportStats getProjectStats(UUID projectId) { + var counts = bugReportRepository.countByStatusForProject(projectId); + return new BugReportStats( + counts.getOrDefault(BugReportStatus.NEW, 0L), + counts.getOrDefault(BugReportStatus.FIXED, 0L), + counts.getOrDefault(BugReportStatus.TESTED, 0L), + counts.getOrDefault(BugReportStatus.CLOSED, 0L) + ); + } + + public record BugReportStats( + long newCount, + long fixedCount, + long testedCount, + long closedCount + ) { + public long total() { + return newCount + fixedCount + testedCount + closedCount; + } + + public long openCount() { + return newCount + fixedCount + testedCount; + } + + public String summary() { + return """ + Bug report statistics: + - New: %d + - Fixed: %d + - Tested: %d + - Closed: %d + - Total open: %d + """ + .formatted( + newCount, + fixedCount, + testedCount, + closedCount, + openCount() + ); + } + } + +} diff --git a/src/main/java/org/lab/service/MilestoneService.java b/src/main/java/org/lab/service/MilestoneService.java new file mode 100644 index 0000000..34a8b67 --- /dev/null +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -0,0 +1,133 @@ +package org.lab.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.lab.exception.ProjectManagementException.InvalidOperationException; +import org.lab.model.Milestone; +import org.lab.model.role.Permission; +import org.lab.model.role.ProjectRole; +import org.lab.model.status.MilestoneStatus; +import org.lab.model.status.TicketStatus; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.TicketRepository; + +public class MilestoneService { + private final MilestoneRepository milestoneRepository; + private final TicketRepository ticketRepository; + private final ProjectService projectService; + + public MilestoneService( + MilestoneRepository milestoneRepository, + TicketRepository ticketRepository, + ProjectService projectService + ) { + this.milestoneRepository = milestoneRepository; + this.ticketRepository = ticketRepository; + this.projectService = projectService; + } + + public Milestone createMilestone( + UUID projectId, + UUID userId, + String name, + String description, + LocalDate startDate, + LocalDate endDate + ) { + ProjectRole role = projectService.getRole(projectId, userId); + role.require(Permission.MANAGE_MILESTONES); + + Milestone milestone = Milestone.of(projectId, name, description, startDate, endDate); + return milestoneRepository.save(milestone); + } + + public Milestone getById(UUID milestoneId) { + return milestoneRepository.getById(milestoneId); + } + + public List findByProjectId(UUID projectId) { + return milestoneRepository.findByProjectId(projectId); + } + + public Optional findActiveMilestone(UUID projectId) { + return milestoneRepository.findActiveByProjectId(projectId); + } + + public Milestone changeStatus(UUID milestoneId, UUID userId, MilestoneStatus newStatus) { + Milestone milestone = getById(milestoneId); + ProjectRole role = projectService.getRole(milestone.projectId(), userId); + role.require(Permission.MANAGE_MILESTONES); + + MilestoneStatus currentStatus = milestone.status(); + boolean validTransition = switch (currentStatus) { + case OPEN -> newStatus == MilestoneStatus.ACTIVE; + case ACTIVE -> newStatus == MilestoneStatus.CLOSED; + case CLOSED -> false; + }; + + if (!validTransition) { + throw new InvalidOperationException("Invalid status transition: %s -> %s".formatted( + currentStatus.getDisplayName(), + newStatus.getDisplayName() + )); + } + + if (newStatus == MilestoneStatus.CLOSED) { + if (!ticketRepository.areAllTicketsDone(milestoneId)) { + throw new InvalidOperationException("Cannot close milestone: not all tickets are done"); + } + } + + if (newStatus == MilestoneStatus.ACTIVE) { + if (milestoneRepository.hasActiveMilestone(milestone.projectId())) { + throw new InvalidOperationException("Project already has an active milestone"); + } + } + + Milestone updated = milestone.withStatus(newStatus); + return milestoneRepository.save(updated); + } + + public Milestone activate(UUID milestoneId, UUID userId) { + return changeStatus(milestoneId, userId, MilestoneStatus.ACTIVE); + } + + public Milestone close(UUID milestoneId, UUID userId) { + return changeStatus(milestoneId, userId, MilestoneStatus.CLOSED); + } + + public MilestoneStats getStats(UUID milestoneId) { + Milestone milestone = getById(milestoneId); + var statusCounts = ticketRepository.countByStatusForProject(milestone.projectId()); + + long total = statusCounts.values().stream().mapToLong(Long::longValue).sum(); + long done = statusCounts.getOrDefault(TicketStatus.DONE, 0L); + + return new MilestoneStats(milestone, total, done); + } + + public record MilestoneStats(Milestone milestone, long totalTickets, long doneTickets) { + public double completionPercentage() { + return totalTickets == 0 ? 0 : (double) doneTickets / totalTickets * 100; + } + + public String summary() { + return """ + Milestone: %s + Status: %s + Progress: %d/%d (%.1f%%) + """ + .formatted( + milestone.name(), + milestone.status().getDisplayName(), + doneTickets, + totalTickets, + completionPercentage() + ); + } + } + +} diff --git a/src/main/java/org/lab/service/ProjectService.java b/src/main/java/org/lab/service/ProjectService.java new file mode 100644 index 0000000..fb7b9cf --- /dev/null +++ b/src/main/java/org/lab/service/ProjectService.java @@ -0,0 +1,141 @@ +package org.lab.service; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BiFunction; + +import org.lab.exception.ProjectManagementException.AccessDeniedException; +import org.lab.model.Project; +import org.lab.model.User; +import org.lab.model.role.Developer; +import org.lab.model.role.Manager; +import org.lab.model.role.Permission; +import org.lab.model.role.ProjectRole; +import org.lab.model.role.TeamLead; +import org.lab.model.role.Tester; +import org.lab.repository.ProjectRepository; + +public class ProjectService { + private final ProjectRepository projectRepository; + private final UserService userService; + + public ProjectService(ProjectRepository projectRepository, UserService userService) { + this.projectRepository = projectRepository; + this.userService = userService; + } + + public Project createProject(String name, String description, UUID creatorId) { + validateUserExists(creatorId); + Project project = Project.of(name, description, creatorId); + return projectRepository.save(project); + } + + public Project getById(UUID projectId) { + return projectRepository.getById(projectId); + } + + public Optional findById(UUID projectId) { + return projectRepository.findById(projectId); + } + + public List findProjectsByUser(UUID userId) { + return projectRepository.findByMemberId(userId); + } + + public Project assignTeamLead(UUID projectId, UUID managerId, UUID teamLeadId) { + return addMember( + projectId, + managerId, + teamLeadId, + Project::withTeamLead + ); + } + + public Project addDeveloper(UUID projectId, UUID managerId, UUID developerId) { + return addMember( + projectId, + managerId, + developerId, + Project::withDeveloper + ); + } + + public Project addTester(UUID projectId, UUID managerId, UUID testerId) { + return addMember( + projectId, + managerId, + testerId, + Project::withTester + ); + } + + private Project addMember( + UUID projectId, + UUID managerId, + UUID memberId, + BiFunction withMember + ) { + Project project = getById(projectId); + validateUserExists(memberId); + + ProjectRole role = getRole(project, managerId); + role.require(Permission.MANAGE_USERS); + + if (project.isMember(memberId)) { + return project; + } + + Project updated = withMember.apply(project, memberId); + return projectRepository.save(updated); + } + + public ProjectRole getRole(Project project, UUID userId) { + return project.getRoleFor(userId) + .orElseThrow(() -> new AccessDeniedException("User is not a project member")); + } + + public ProjectRole getRole(UUID projectId, UUID userId) { + Project project = getById(projectId); + return getRole(project, userId); + } + + public boolean isMember(UUID projectId, UUID userId) { + return projectRepository.findById(projectId) + .map(project -> project.isMember(userId)) + .orElse(false); + } + + public List getProjectTeam(UUID projectId) { + Project project = getById(projectId); + + return project.getAllMemberIds().stream() + .map(userId -> { + User user = userService.findById(userId).orElse(null); + ProjectRole role = project.getRoleFor(userId).orElse(null); + return new UserRoleInfo(user, role, getRoleDescription(project, role)); + }) + .filter(info -> info.user() != null && info.role() != null) + .toList(); + } + + public record UserRoleInfo(User user, ProjectRole role, String description) { + } + + private String getRoleDescription(Project project, ProjectRole projectRole) { + if (projectRole == null) { + return "Not a project member"; + } + + return switch (projectRole) { + case Manager _ -> "Manager of project '%s'".formatted(project.name()); + case TeamLead _ -> "Team Lead of project '%s'".formatted(project.name()); + case Developer _ -> "Developer of project '%s'".formatted(project.name()); + case Tester _ -> "Tester of project '%s'".formatted(project.name()); + }; + } + + private void validateUserExists(UUID userId) { + userService.getById(userId); + } +} diff --git a/src/main/java/org/lab/service/ProjectSummaryService.java b/src/main/java/org/lab/service/ProjectSummaryService.java new file mode 100644 index 0000000..e911cb2 --- /dev/null +++ b/src/main/java/org/lab/service/ProjectSummaryService.java @@ -0,0 +1,305 @@ +package org.lab.service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.StructuredTaskScope; +import java.util.stream.Collectors; + +import org.lab.model.BugReport; +import org.lab.model.BugSeverity; +import org.lab.model.Milestone; +import org.lab.model.Project; +import org.lab.model.Ticket; +import org.lab.model.status.BugReportStatus; +import org.lab.model.status.TicketStatus; +import org.lab.repository.BugReportRepository; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.TicketRepository; + +public class ProjectSummaryService { + private final ProjectService projectService; + private final TicketRepository ticketRepository; + private final BugReportRepository bugReportRepository; + private final MilestoneRepository milestoneRepository; + + public ProjectSummaryService( + ProjectService projectService, + TicketRepository ticketRepository, + BugReportRepository bugReportRepository, + MilestoneRepository milestoneRepository + ) { + this.projectService = projectService; + this.ticketRepository = ticketRepository; + this.bugReportRepository = bugReportRepository; + this.milestoneRepository = milestoneRepository; + } + + public ProjectSummary getProjectSummary(UUID projectId) throws InterruptedException, ExecutionException { + Project project = projectService.getById(projectId); + + try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + var ticketsTask = scope.fork(() -> ticketRepository.findByProjectId(projectId)); + var bugsTask = scope.fork(() -> bugReportRepository.findByProjectId(projectId)); + var milestonesTask = scope.fork(() -> milestoneRepository.findByProjectId(projectId)); + var activeMilestoneTask = scope.fork(() -> milestoneRepository.findActiveByProjectId(projectId)); + + scope.join(); + scope.throwIfFailed(); + + List tickets = ticketsTask.get(); + List bugs = bugsTask.get(); + List milestones = milestonesTask.get(); + Optional activeMilestone = activeMilestoneTask.get(); + + TicketSummary ticketSummary = buildTicketSummary(tickets); + BugSummary bugSummary = buildBugSummary(bugs); + + return new ProjectSummary( + project, + milestones, + activeMilestone.orElse(null), + ticketSummary, + bugSummary + ); + } + } + + public List getProjectsSummaries(List projectIds) + throws InterruptedException, ExecutionException { + + try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + var tasks = projectIds.stream() + .map(id -> scope.fork(() -> getProjectSummary(id))) + .toList(); + + scope.join(); + scope.throwIfFailed(); + + return tasks.stream() + .map(StructuredTaskScope.Subtask::get) + .toList(); + } + } + + public UserActivitySummary getUserActivitySummary(UUID userId) throws InterruptedException, ExecutionException { + List userProjects = projectService.findProjectsByUser(userId); + + try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + var assignedTicketsTask = scope.fork(() -> + ticketRepository.findByAssigneeId(userId)); + var assignedBugsTask = scope.fork(() -> + bugReportRepository.findByAssigneeId(userId)); + var reportedBugsTask = scope.fork(() -> + bugReportRepository.findByReporterId(userId)); + + scope.join(); + scope.throwIfFailed(); + + List assignedTickets = assignedTicketsTask.get(); + List assignedBugs = assignedBugsTask.get(); + List reportedBugs = reportedBugsTask.get(); + + List pendingTickets = assignedTickets.stream() + .filter(t -> t.status() != TicketStatus.DONE) + .toList(); + + List openBugs = assignedBugs.stream() + .filter(b -> !b.isClosed()) + .toList(); + + return new UserActivitySummary( + userProjects.size(), + assignedTickets.size(), + pendingTickets.size(), + assignedBugs.size(), + openBugs.size(), + reportedBugs.size(), + pendingTickets, + openBugs + ); + } + } + + private TicketSummary buildTicketSummary(List tickets) { + Map byStatus = tickets.stream() + .collect(Collectors.groupingBy( + Ticket::status, + Collectors.counting() + )); + + return new TicketSummary( + tickets.size(), + byStatus.getOrDefault(TicketStatus.NEW, 0L).intValue(), + byStatus.getOrDefault(TicketStatus.ACCEPTED, 0L).intValue(), + byStatus.getOrDefault(TicketStatus.IN_PROGRESS, 0L).intValue(), + byStatus.getOrDefault(TicketStatus.DONE, 0L).intValue() + ); + } + + private BugSummary buildBugSummary(List bugs) { + Map byStatus = bugs.stream() + .collect(Collectors.groupingBy( + BugReport::status, + Collectors.counting() + )); + + long criticalCount = bugs.stream() + .filter(b -> b.severity() == BugSeverity.CRITICAL) + .filter(b -> !b.isClosed()) + .count(); + + return new BugSummary( + bugs.size(), + byStatus.getOrDefault(BugReportStatus.NEW, 0L).intValue(), + byStatus.getOrDefault(BugReportStatus.FIXED, 0L).intValue(), + byStatus.getOrDefault(BugReportStatus.TESTED, 0L).intValue(), + byStatus.getOrDefault(BugReportStatus.CLOSED, 0L).intValue(), + (int) criticalCount + ); + } + + public record ProjectSummary( + Project project, + List milestones, + Milestone activeMilestone, + TicketSummary ticketSummary, + BugSummary bugSummary + ) { + public String generateReport() { + String activeMilestoneInfo = activeMilestone != null + ? activeMilestone.name() + : "none"; + + return """ + =============================================== + Project Summary: %s + =============================================== + + Milestones: %d (active: %s) + + Tickets: + %s + + Bug Reports: + %s + + Members: %d + =============================================== + """ + .formatted( + project.name(), + milestones.size(), + activeMilestoneInfo, + ticketSummary.format(), + bugSummary.format(), + project.getMemberCount() + ); + } + } + + public record TicketSummary( + int total, + int newCount, + int acceptedCount, + int inProgressCount, + int doneCount + ) { + public double completionRate() { + return total == 0 ? 0 : (double) doneCount / total * 100; + } + + public String format() { + return """ + Total: %d + - New: %d + - Accepted: %d + - In Progress: %d + - Done: %d (%.1f%%)""" + .formatted( + total, + newCount, + acceptedCount, + inProgressCount, + doneCount, + completionRate() + ); + } + } + + public record BugSummary( + int total, + int newCount, + int fixedCount, + int testedCount, + int closedCount, + int criticalOpenCount + ) { + public int openCount() { + return newCount + fixedCount + testedCount; + } + + public String format() { + String criticalWarning = criticalOpenCount > 0 + ? STR." Critical open: \{criticalOpenCount}" + : ""; + + return """ + Total: %d (open: %d)%s + - New: %d + - Fixed: %d + - Tested: %d + - Closed: %d""" + .formatted( + total, + openCount(), + criticalWarning, + newCount, + fixedCount, + testedCount, + closedCount + ); + } + } + + public record UserActivitySummary( + int projectCount, + int totalAssignedTickets, + int pendingTickets, + int totalAssignedBugs, + int openBugs, + int reportedBugs, + List pendingTicketList, + List openBugList + ) { + public String format() { + return """ + =============================================== + User Activity Summary + =============================================== + + Projects: %d + + Tickets: + - Total assigned: %d + - Pending: %d + + Bugs: + - Assigned to fix: %d + - Open: %d + - Reported: %d + =============================================== + """ + .formatted( + projectCount, + totalAssignedTickets, + pendingTickets, + totalAssignedBugs, + openBugs, + reportedBugs + ); + } + } +} diff --git a/src/main/java/org/lab/service/TicketService.java b/src/main/java/org/lab/service/TicketService.java new file mode 100644 index 0000000..0ee7178 --- /dev/null +++ b/src/main/java/org/lab/service/TicketService.java @@ -0,0 +1,198 @@ +package org.lab.service; + +import org.lab.exception.ProjectManagementException.AccessDeniedException; +import org.lab.exception.ProjectManagementException.InvalidOperationException; +import org.lab.model.Milestone; +import org.lab.model.Ticket; +import org.lab.model.role.Developer; +import org.lab.model.role.Permission; +import org.lab.model.role.ProjectRole; +import org.lab.model.status.MilestoneStatus; +import org.lab.model.status.TicketStatus; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.TicketRepository; + +import java.util.List; +import java.util.UUID; + +public class TicketService { + private final TicketRepository ticketRepository; + private final MilestoneRepository milestoneRepository; + private final ProjectService projectService; + + public TicketService( + TicketRepository ticketRepository, + MilestoneRepository milestoneRepository, + ProjectService projectService + ) { + this.ticketRepository = ticketRepository; + this.milestoneRepository = milestoneRepository; + this.projectService = projectService; + } + + public Ticket createTicket( + UUID projectId, + UUID milestoneId, + UUID userId, + String title, + String description + ) { + ProjectRole role = projectService.getRole(projectId, userId); + role.require(Permission.CREATE_TICKETS); + + Milestone milestone = milestoneRepository.getById(milestoneId); + + if (!milestone.projectId().equals(projectId)) { + throw new InvalidOperationException("Milestone does not belong to the specified project"); + } + + if (milestone.status() == MilestoneStatus.CLOSED) { + throw new InvalidOperationException("Cannot create tickets in a closed milestone"); + } + + Ticket ticket = Ticket.of(projectId, milestoneId, title, description, userId); + return ticketRepository.save(ticket); + } + + public Ticket findById(UUID ticketId) { + return ticketRepository.getById(ticketId); + } + + public List findAssignedToUser(UUID userId) { + return ticketRepository.findByAssigneeId(userId); + } + + public List findPendingForUser(UUID userId) { + return ticketRepository.findPendingByAssigneeId(userId); + } + + public List findByProject(UUID projectId) { + return ticketRepository.findByProjectId(projectId); + } + + public List findByMilestone(UUID milestoneId) { + return ticketRepository.findByMilestoneId(milestoneId); + } + + public Ticket assignDeveloper(UUID ticketId, UUID assignerId, UUID developerId) { + Ticket ticket = findById(ticketId); + ProjectRole assignerRole = projectService.getRole(ticket.projectId(), assignerId); + ProjectRole developerRole = projectService.getRole(ticket.projectId(), developerId); + + assignerRole.require(Permission.CREATE_TICKETS); + + if (!developerRole.hasPermission(Permission.EXECUTE_TICKETS)) { + throw new InvalidOperationException("User cannot execute tickets"); + } + + Ticket updated = ticket.withAssignedDeveloper(developerId); + return ticketRepository.save(updated); + } + + public Ticket changeStatus(UUID ticketId, UUID userId, TicketStatus newStatus) { + Ticket ticket = findById(ticketId); + ProjectRole role = projectService.getRole(ticket.projectId(), userId); + + boolean canChange = switch (role) { + case Developer _ -> ticket.isAssignedTo(userId) + && (newStatus == TicketStatus.ACCEPTED + || newStatus == TicketStatus.IN_PROGRESS + || newStatus == TicketStatus.DONE); + default -> role.hasPermission(Permission.REVIEW_TICKETS); + }; + + if (!canChange) { + throw new AccessDeniedException("Cannot change ticket status"); + } + + if (!ticket.status().canTransitionTo(newStatus)) { + throw new InvalidOperationException( + "Invalid status transition: %s -> %s".formatted( + ticket.status().getDisplayName(), newStatus.getDisplayName())); + } + + Ticket updated = ticket.withStatus(newStatus); + return ticketRepository.save(updated); + } + + public Ticket acceptTicket(UUID ticketId, UUID developerId) { + Ticket ticket = findById(ticketId); + + if (!ticket.isAssignedTo(developerId)) { + ticket = ticket.withAssignedDeveloper(developerId); + ticketRepository.save(ticket); + } + + return changeStatus(ticketId, developerId, TicketStatus.ACCEPTED); + } + + public Ticket startProgress(UUID ticketId, UUID developerId) { + return changeStatus(ticketId, developerId, TicketStatus.IN_PROGRESS); + } + + public Ticket completeTicket(UUID ticketId, UUID developerId) { + return changeStatus(ticketId, developerId, TicketStatus.DONE); + } + + public TicketReviewResult reviewTicket(UUID ticketId, UUID reviewerId, boolean approved) { + Ticket ticket = findById(ticketId); + ProjectRole role = projectService.getRole(ticket.projectId(), reviewerId); + + role.require(Permission.REVIEW_TICKETS); + + if (ticket.status() != TicketStatus.DONE) { + throw new InvalidOperationException("Can only review completed tickets"); + } + + if (approved) { + return new TicketReviewResult(ticket, true, "Ticket approved"); + } else { + Ticket reopened = ticket.withStatus(TicketStatus.IN_PROGRESS); + ticketRepository.save(reopened); + return new TicketReviewResult(reopened, false, "Ticket returned for rework"); + } + } + + public record TicketReviewResult(Ticket ticket, boolean approved, String message) { + } + + public TicketStats getProjectStats(UUID projectId) { + var counts = ticketRepository.countByStatusForProject(projectId); + return new TicketStats( + counts.getOrDefault(TicketStatus.NEW, 0L), + counts.getOrDefault(TicketStatus.ACCEPTED, 0L), + counts.getOrDefault(TicketStatus.IN_PROGRESS, 0L), + counts.getOrDefault(TicketStatus.DONE, 0L) + ); + } + + public record TicketStats( + long newCount, + long acceptedCount, + long inProgressCount, + long doneCount + ) { + public long total() { + return newCount + acceptedCount + inProgressCount + doneCount; + } + + public String summary() { + return """ + Ticket statistics: + - New: %d + - Accepted: %d + - In Progress: %d + - Done: %d + - Total: %d + """ + .formatted( + newCount, + acceptedCount, + inProgressCount, + doneCount, + total() + ); + } + } + +} diff --git a/src/main/java/org/lab/service/UserService.java b/src/main/java/org/lab/service/UserService.java new file mode 100644 index 0000000..0a14c23 --- /dev/null +++ b/src/main/java/org/lab/service/UserService.java @@ -0,0 +1,59 @@ +package org.lab.service; + +import org.lab.exception.ProjectManagementException.DuplicateEntityException; +import org.lab.exception.ProjectManagementException.EntityNotFoundException; +import org.lab.model.User; +import org.lab.repository.UserRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public class UserService { + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public User register(String username, String email, String fullName) { + if (userRepository.existsByUsername(username)) { + throw new DuplicateEntityException("User", "username", username); + } + + if (userRepository.existsByEmail(email)) { + throw new DuplicateEntityException("User", "email", email); + } + + User user = User.of(username, email, fullName); + return userRepository.save(user); + } + + public User getById(UUID userId) { + return userRepository.getById(userId); + } + + public Optional findById(UUID userId) { + return userRepository.findById(userId); + } + + public User findByUsername(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new EntityNotFoundException("User", username)); + } + + public List findAll() { + return userRepository.findAll(); + } + + public boolean exists(UUID userId) { + return userRepository.existsById(userId); + } + + public List findByIds(List userIds) { + return userIds.stream() + .map(userRepository::findById) + .flatMap(Optional::stream) + .toList(); + } +}