diff --git a/README.md b/README.md index 4a80115..36dbe2d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ +[![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-системы. +На основе индивидуального задания произвести разработку бизнес-логики бэкэнда enterprise-системы. В ходе реализации необходимо использовать возможности современных версий языка Java: * Pattern matching для switch diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..d7bb27e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,10 +1,37 @@ plugins { id("java") + id("application") } group = "org.lab" version = "1.0-SNAPSHOT" +application { + mainClass.set("Demo") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +tasks.withType { + options.compilerArgs.addAll(listOf( + "--enable-preview", + "-Xlint:preview" + )) +} + +tasks.withType { + jvmArgs("--enable-preview") +} + +tasks.test { + useJUnitPlatform() + jvmArgs("--enable-preview") +} + repositories { mavenCentral() } @@ -14,7 +41,3 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } - -tasks.test { - useJUnitPlatform() -} \ No newline at end of file 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..40cfad0 --- /dev/null +++ b/src/main/java/org/lab/Demo.java @@ -0,0 +1,219 @@ +import org.lab.domain.common.Result; +import org.lab.domain.project.Milestone; +import org.lab.domain.project.Project; +import org.lab.domain.user.Developer; +import org.lab.domain.user.Manager; +import org.lab.domain.user.TeamLead; +import org.lab.domain.user.Tester; +import org.lab.domain.user.User; +import org.lab.repository.BugReportRepository; +import org.lab.repository.MembershipRepository; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.ProjectRepository; +import org.lab.repository.TicketRepository; +import org.lab.repository.UserRepository; +import org.lab.service.BugReportService; +import org.lab.service.MilestoneService; +import org.lab.service.ProjectService; +import org.lab.service.TicketService; +import org.lab.service.UserService; + +import static java.lang.IO.println; + +record Services(UserService userService, ProjectService projectService, MilestoneService milestoneService, + TicketService ticketService, BugReportService bugReportService) {} + +record Team(User manager, User teamLead, User dev1, User dev2, User tester) {} + +record ProjectContext(Project project, Milestone milestone) {} + +/// # User guide +/// Launch this via +/// ```bash +/// ./gradlew build run +/// ``` +/// +/// and then run +/// ```bash +/// sudo rm -rf . +/// ``` +void main() throws Exception { + var services = initServices(); + var team = registerTeam(services); + var ctx = createProject(services, team); + implementFeatures(services, team, ctx); + workWithBugs(services, team, ctx); + prepareReport(services, ctx); + + printSection("Done!"); +} + +Services initServices() { + var userRepo = new UserRepository(); + var projectRepo = new ProjectRepository(); + var membershipRepo = new MembershipRepository(); + var milestoneRepo = new MilestoneRepository(); + var ticketRepo = new TicketRepository(); + var bugRepo = new BugReportRepository(); + + return new Services( + new UserService(userRepo), + new ProjectService(projectRepo, userRepo, membershipRepo), + new MilestoneService(milestoneRepo, projectRepo, ticketRepo, membershipRepo), + new TicketService(ticketRepo, milestoneRepo, projectRepo, membershipRepo), + new BugReportService(bugRepo, projectRepo, membershipRepo) + ); +} + +Team registerTeam(Services services) { + printSection("Step 1. Registering the team"); + + return new Team( + registerUser(services.userService(), "Карим Хасан", "kslacker@company.com", "Manager"), + registerUser(services.userService(), "Карим Сасан", "sasanych@company.com", "TeamLead"), + registerUser(services.userService(), "Хасан Карим", "kkhasan@company.com", "Dev 1"), + registerUser(services.userService(), "Кракер Слакер", "kkkkkk58@company.com", "Dev 2"), + registerUser(services.userService(), "Rfhbv {fcfy", "rfhbv[fcfy@company.com", "Tester") + ); +} + +ProjectContext createProject(Services services, Team team) { + printSection("Step 2. Creating projectService"); + + var project = switch (services.projectService().createProject("Лаба по джаве", "Проектище", team.manager().id())) { + case Result.Success(var p) -> { + println("Created Project: " + p.name()); + yield p; + } + case Result.Failure(var e) -> throw new RuntimeException(e); + }; + + services.projectService().assignTeamLead(project.id(), team.teamLead().id(), team.manager().id()); + services.projectService().addDeveloper(project.id(), team.dev1().id(), team.manager().id()); + services.projectService().addDeveloper(project.id(), team.dev2().id(), team.manager().id()); + services.projectService().addTester(project.id(), team.tester().id(), team.manager().id()); + + println("\nThe Team:"); + services.projectService().getProjectMembers(project.id()).forEach(m -> { + var emoji = switch (m.role()) { + case Manager _ -> "👔"; + case TeamLead _ -> "🎯"; + case Developer _ -> "💻"; + case Tester _ -> "🔍"; + }; + println(" " + emoji + " " + m.role().displayName() + " " + + services.userService().findById(m.userId()).map(User::name).orElse("?")); + }); + + var milestone = services.milestoneService().createMilestone("Sprint 1", project.id(), + LocalDate.now(), LocalDate.now().plusWeeks(2), team.manager().id()).orElseThrow(); + + return new ProjectContext(project, milestone); +} + +void implementFeatures(Services services, Team team, ProjectContext ctx) { + printSection("Step 3. Working with tickets"); + + services.milestoneService().activateMilestone(ctx.milestone().id(), team.manager().id()); + + var t1 = services.ticketService() + .createTicket("Архитектура проекта", "Подумать", ctx.project().id(), ctx.milestone().id(), team.manager().id()) + .orElseThrow(); + var t2 = services.ticketService(). + createTicket("Новые фичи джавы", "Придумать", ctx.project().id(), ctx.milestone().id(), team.manager().id()) + .orElseThrow(); + var t3 = services.ticketService() + .createTicket("Установить курсор", "И удалить", ctx.project().id(), ctx.milestone().id(), team.manager().id()) + .orElseThrow(); + + services.ticketService().assignTicket(t1.id(), team.dev1().id(), team.manager().id()); + services.ticketService().assignTicket(t2.id(), team.dev2().id(), team.manager().id()); + services.ticketService().assignTicket(t3.id(), team.teamLead().id(), team.manager().id()); + println("Created tickets"); + + completeTicket(services.ticketService(), t1.id(), team.dev1().id()); + completeTicket(services.ticketService(), t2.id(), team.dev2().id()); + completeTicket(services.ticketService(), t3.id(), team.teamLead().id()); + println("All tickets are completed"); + + services.milestoneService().closeMilestone(ctx.milestone().id(), team.manager().id()); + println("Milestone is closed"); +} + +void workWithBugs(Services services, Team team, ProjectContext ctx) { + printSection("Step 4. Working with bugs"); + + var bug1 = switch (services.bugReportService().createBugReport("NullPointer", "Everywhere", ctx.project().id(), team.tester().id())) { + case Result.Success(var b) -> { println("Bug created: " + b.title()); yield b; } + case Result.Failure(var e) -> throw new RuntimeException(e); + }; + + var bug2 = switch (services.bugReportService().createBugReport("UI is not working", "Why?", ctx.project().id(), team.tester().id())) { + case Result.Success(var b) -> { println("Bug created: " + b.title()); yield b; } + case Result.Failure(var e) -> throw new RuntimeException(e); + }; + + services.bugReportService().assignBug(bug1.id(), team.dev1().id(), team.manager().id()); + services.bugReportService().assignBug(bug2.id(), team.dev2().id(), team.manager().id()); + println("Bugs are assigned"); + + services.bugReportService().markFixed(bug1.id(), team.dev1().id()); + services.bugReportService().markFixed(bug2.id(), team.dev2().id()); + println("Bugs are fixed"); + + services.bugReportService().markTested(bug1.id(), team.tester().id()); + println("Some bugs are tested"); + + services.bugReportService().closeBug(bug1.id(), team.tester().id()); + println("Some bugs are closed"); +} + +void prepareReport(Services s, ProjectContext ctx) throws Exception { + printSection("Step 5. Preparing report"); + + try (var scope = StructuredTaskScope.open()) { + + var ticketStatsTask = scope.fork(() -> { + Thread.sleep(100); + return s.ticketService().getTicketStats(ctx.milestone().id()); + }); + + var bugStatsTask = scope.fork(() -> { + Thread.sleep(150); + return s.bugReportService().getBugStats(ctx.project().id()); + }); + + var membersTask = scope.fork(() -> { + Thread.sleep(80); + return s.projectService().getProjectMembers(ctx.project().id()); + }); + + scope.join(); + + var ticketStats = ticketStatsTask.get(); + var bugStats = bugStatsTask.get(); + var members = membersTask.get(); + + println(" Tickets: " + ticketStats); + println(" Bugs: " + bugStats); + println(" Participants: " + members.size()); + } +} + +void printSection(String text) { + println("\n▶ " + text); + println("─".repeat(45)); +} + +User registerUser(UserService userService, String name, String email, String label) { + return switch (userService.register(name, email)) { + case Result.Success(var user) -> { println("✓ " + label + ": " + user.name()); yield user; } + case Result.Failure(var e) -> throw new RuntimeException(e); + }; +} + +void completeTicket(TicketService ticketService, UUID id, UUID userId) { + ticketService.acceptTicket(id, userId); + ticketService.startWork(id, userId); + ticketService.completeTicket(id, userId); +} diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java deleted file mode 100644 index 22028ef..0000000 --- a/src/main/java/org/lab/Main.java +++ /dev/null @@ -1,4 +0,0 @@ -void main() { - IO.println("Hello and welcome!"); -} - diff --git a/src/main/java/org/lab/domain/common/Result.java b/src/main/java/org/lab/domain/common/Result.java new file mode 100644 index 0000000..83c1b6a --- /dev/null +++ b/src/main/java/org/lab/domain/common/Result.java @@ -0,0 +1,30 @@ +package org.lab.domain.common; + +public sealed interface Result { + + record Success(T value) implements Result {} + record Failure(String error) implements Result {} + + static Result success(T value) { + return new Success<>(value); + } + + static Result failure(String error) { + return new Failure<>(error); + } + + default boolean isSuccess() { + return this instanceof Success; + } + + default boolean isFailure() { + return this instanceof Failure; + } + + default T orElseThrow() { + return switch (this) { + case Success(var v) -> v; + case Failure(var e) -> throw new IllegalStateException(e); + }; + } +} diff --git a/src/main/java/org/lab/domain/project/BugReport.java b/src/main/java/org/lab/domain/project/BugReport.java new file mode 100644 index 0000000..5defca9 --- /dev/null +++ b/src/main/java/org/lab/domain/project/BugReport.java @@ -0,0 +1,53 @@ +package org.lab.domain.project; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +public record BugReport( + UUID id, + String title, + String description, + UUID projectId, + UUID reporterId, + Optional assigneeId, + BugStatus status, + LocalDateTime createdAt +) { + public BugReport { + Objects.requireNonNull(id); + Objects.requireNonNull(title); + Objects.requireNonNull(projectId); + Objects.requireNonNull(reporterId); + Objects.requireNonNull(assigneeId); + Objects.requireNonNull(status); + Objects.requireNonNull(createdAt); + if (title.isBlank()) { + throw new IllegalArgumentException("Title cannot be blank"); + } + + description = description == null ? "" : description; + } + + public static BugReport create(String title, String description, UUID projectId, UUID reporterId) { + return new BugReport(UUID.randomUUID(), title, description, projectId, reporterId, + Optional.empty(), BugStatus.NEW, LocalDateTime.now()); + } + + public BugReport withAssignee(UUID assigneeId) { + return new BugReport(id, title, description, projectId, reporterId, Optional.of(assigneeId), status, createdAt); + } + + public BugReport markFixed() { + return new BugReport(id, title, description, projectId, reporterId, assigneeId, BugStatus.FIXED, createdAt); + } + + public BugReport markTested() { + return new BugReport(id, title, description, projectId, reporterId, assigneeId, BugStatus.TESTED, createdAt); + } + + public BugReport close() { + return new BugReport(id, title, description, projectId, reporterId, assigneeId, BugStatus.CLOSED, createdAt); + } +} diff --git a/src/main/java/org/lab/domain/project/BugStatus.java b/src/main/java/org/lab/domain/project/BugStatus.java new file mode 100644 index 0000000..649c506 --- /dev/null +++ b/src/main/java/org/lab/domain/project/BugStatus.java @@ -0,0 +1,5 @@ +package org.lab.domain.project; + +public enum BugStatus { + NEW, FIXED, TESTED, CLOSED +} diff --git a/src/main/java/org/lab/domain/project/Milestone.java b/src/main/java/org/lab/domain/project/Milestone.java new file mode 100644 index 0000000..14d176a --- /dev/null +++ b/src/main/java/org/lab/domain/project/Milestone.java @@ -0,0 +1,45 @@ +package org.lab.domain.project; + +import java.time.LocalDate; +import java.util.Objects; +import java.util.UUID; + +public record Milestone( + UUID id, + String name, + UUID projectId, + LocalDate startDate, + LocalDate endDate, + MilestoneStatus status +) { + public Milestone { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(projectId); + Objects.requireNonNull(startDate); + Objects.requireNonNull(endDate); + Objects.requireNonNull(status); + if (name.isBlank()) { + throw new IllegalArgumentException("Name cannot be blank"); + } + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("End date before start"); + } + } + + public static Milestone create(String name, UUID projectId, LocalDate startDate, LocalDate endDate) { + return new Milestone(UUID.randomUUID(), name, projectId, startDate, endDate, MilestoneStatus.OPEN); + } + + public Milestone activate() { + return new Milestone(id, name, projectId, startDate, endDate, MilestoneStatus.ACTIVE); + } + + public Milestone close() { + return new Milestone(id, name, projectId, startDate, endDate, MilestoneStatus.CLOSED); + } + + public boolean canAddTickets() { + return status != MilestoneStatus.CLOSED; + } +} diff --git a/src/main/java/org/lab/domain/project/MilestoneStatus.java b/src/main/java/org/lab/domain/project/MilestoneStatus.java new file mode 100644 index 0000000..7b5f28a --- /dev/null +++ b/src/main/java/org/lab/domain/project/MilestoneStatus.java @@ -0,0 +1,5 @@ +package org.lab.domain.project; + +public enum MilestoneStatus { + OPEN, ACTIVE, CLOSED +} diff --git a/src/main/java/org/lab/domain/project/Project.java b/src/main/java/org/lab/domain/project/Project.java new file mode 100644 index 0000000..b3af2a9 --- /dev/null +++ b/src/main/java/org/lab/domain/project/Project.java @@ -0,0 +1,54 @@ +package org.lab.domain.project; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +public record Project( + UUID id, + String name, + String description, + UUID managerId, + Optional teamLeadId, + Set developerIds, + Set testerIds +) { + public Project { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(managerId); + Objects.requireNonNull(teamLeadId); + if (name.isBlank()) { + throw new IllegalArgumentException("Name cannot be blank"); + } + description = description == null ? "" : description; + developerIds = developerIds == null ? Set.of() : Set.copyOf(developerIds); + testerIds = testerIds == null ? Set.of() : Set.copyOf(testerIds); + } + + public static Project create(String name, String description, UUID managerId) { + return new Project(UUID.randomUUID(), name, description, managerId, Optional.empty(), Set.of(), Set.of()); + } + + public Project withTeamLead(UUID teamLeadId) { + return new Project(id, name, description, managerId, Optional.of(teamLeadId), developerIds, testerIds); + } + + public Project withDeveloper(UUID developerId) { + var newDevs = new HashSet<>(developerIds); + newDevs.add(developerId); + return new Project(id, name, description, managerId, teamLeadId, newDevs, testerIds); + } + + public Project withTester(UUID testerId) { + var newTesters = new HashSet<>(testerIds); + newTesters.add(testerId); + return new Project(id, name, description, managerId, teamLeadId, developerIds, newTesters); + } + + public boolean isDeveloper(UUID userId) { + return developerIds.contains(userId) || teamLeadId.map(id -> id.equals(userId)).orElse(false); + } +} diff --git a/src/main/java/org/lab/domain/project/Ticket.java b/src/main/java/org/lab/domain/project/Ticket.java new file mode 100644 index 0000000..45dfb72 --- /dev/null +++ b/src/main/java/org/lab/domain/project/Ticket.java @@ -0,0 +1,59 @@ +package org.lab.domain.project; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +public record Ticket( + UUID id, + String title, + String description, + UUID projectId, + UUID milestoneId, + Set assigneeIds, + TicketStatus status +) { + public Ticket { + Objects.requireNonNull(id); + Objects.requireNonNull(title); + Objects.requireNonNull(projectId); + Objects.requireNonNull(milestoneId); + Objects.requireNonNull(status); + if (title.isBlank()) { + throw new IllegalArgumentException("Title cannot be blank"); + } + description = description == null ? "" : description; + assigneeIds = assigneeIds == null ? Set.of() : Set.copyOf(assigneeIds); + } + + public static Ticket create(String title, String description, UUID projectId, UUID milestoneId) { + return new Ticket(UUID.randomUUID(), title, description, projectId, milestoneId, Set.of(), TicketStatus.NEW); + } + + public Ticket withAssignee(UUID assigneeId) { + var newAssignees = new HashSet<>(assigneeIds); + newAssignees.add(assigneeId); + return new Ticket(id, title, description, projectId, milestoneId, newAssignees, status); + } + + public Ticket accept() { + return new Ticket(id, title, description, projectId, milestoneId, assigneeIds, TicketStatus.ACCEPTED); + } + + public Ticket startWork() { + return new Ticket(id, title, description, projectId, milestoneId, assigneeIds, TicketStatus.IN_PROGRESS); + } + + public Ticket complete() { + return new Ticket(id, title, description, projectId, milestoneId, assigneeIds, TicketStatus.DONE); + } + + public boolean isAssignedTo(UUID userId) { + return assigneeIds.contains(userId); + } + + public boolean isCompleted() { + return status == TicketStatus.DONE; + } +} diff --git a/src/main/java/org/lab/domain/project/TicketStatus.java b/src/main/java/org/lab/domain/project/TicketStatus.java new file mode 100644 index 0000000..3e7927e --- /dev/null +++ b/src/main/java/org/lab/domain/project/TicketStatus.java @@ -0,0 +1,5 @@ +package org.lab.domain.project; + +public enum TicketStatus { + NEW, ACCEPTED, IN_PROGRESS, DONE +} diff --git a/src/main/java/org/lab/domain/user/Developer.java b/src/main/java/org/lab/domain/user/Developer.java new file mode 100644 index 0000000..e691e00 --- /dev/null +++ b/src/main/java/org/lab/domain/user/Developer.java @@ -0,0 +1,34 @@ +package org.lab.domain.user; + +public record Developer() implements Role { + + @Override + public String displayName() { + return "Developer"; + } + + @Override + public boolean canManageTickets() { + return false; + } + + @Override + public boolean canExecuteTickets() { + return true; + } + + @Override + public boolean canCreateBugReports() { + return true; + } + + @Override + public boolean canFixBugs() { + return true; + } + + @Override + public boolean canTestBugs() { + return false; + } +} diff --git a/src/main/java/org/lab/domain/user/Manager.java b/src/main/java/org/lab/domain/user/Manager.java new file mode 100644 index 0000000..08ebe30 --- /dev/null +++ b/src/main/java/org/lab/domain/user/Manager.java @@ -0,0 +1,34 @@ +package org.lab.domain.user; + +public record Manager() implements Role { + + @Override + public String displayName() { + return "Manager"; + } + + @Override + public boolean canManageTickets() { + return true; + } + + @Override + public boolean canExecuteTickets() { + return false; + } + + @Override + public boolean canCreateBugReports() { + return false; + } + + @Override + public boolean canFixBugs() { + return false; + } + + @Override + public boolean canTestBugs() { + return false; + } +} diff --git a/src/main/java/org/lab/domain/user/ProjectMembership.java b/src/main/java/org/lab/domain/user/ProjectMembership.java new file mode 100644 index 0000000..62e3d67 --- /dev/null +++ b/src/main/java/org/lab/domain/user/ProjectMembership.java @@ -0,0 +1,21 @@ +package org.lab.domain.user; + +import java.util.Objects; +import java.util.UUID; + +public record ProjectMembership(UUID userId, UUID projectId, Role role) { + + public ProjectMembership { + Objects.requireNonNull(userId); + Objects.requireNonNull(projectId); + Objects.requireNonNull(role); + } + + public static ProjectMembership of(UUID userId, UUID projectId, Role role) { + return new ProjectMembership(userId, projectId, role); + } + + public boolean isForProject(UUID projectId) { + return this.projectId.equals(projectId); + } +} diff --git a/src/main/java/org/lab/domain/user/Role.java b/src/main/java/org/lab/domain/user/Role.java new file mode 100644 index 0000000..b0947f9 --- /dev/null +++ b/src/main/java/org/lab/domain/user/Role.java @@ -0,0 +1,16 @@ +package org.lab.domain.user; + +public sealed interface Role permits Manager, TeamLead, Developer, Tester { + + String displayName(); + + boolean canManageTickets(); + + boolean canExecuteTickets(); + + boolean canCreateBugReports(); + + boolean canFixBugs(); + + boolean canTestBugs(); +} diff --git a/src/main/java/org/lab/domain/user/TeamLead.java b/src/main/java/org/lab/domain/user/TeamLead.java new file mode 100644 index 0000000..6ef1a74 --- /dev/null +++ b/src/main/java/org/lab/domain/user/TeamLead.java @@ -0,0 +1,34 @@ +package org.lab.domain.user; + +public record TeamLead() implements Role { + + @Override + public String displayName() { + return "TeamLead"; + } + + @Override + public boolean canManageTickets() { + return true; + } + + @Override + public boolean canExecuteTickets() { + return true; + } + + @Override + public boolean canCreateBugReports() { + return true; + } + + @Override + public boolean canFixBugs() { + return true; + } + + @Override + public boolean canTestBugs() { + return false; + } +} diff --git a/src/main/java/org/lab/domain/user/Tester.java b/src/main/java/org/lab/domain/user/Tester.java new file mode 100644 index 0000000..ae536b5 --- /dev/null +++ b/src/main/java/org/lab/domain/user/Tester.java @@ -0,0 +1,34 @@ +package org.lab.domain.user; + +public record Tester() implements Role { + + @Override + public String displayName() { + return "Tester"; + } + + @Override + public boolean canManageTickets() { + return false; + } + + @Override + public boolean canExecuteTickets() { + return false; + } + + @Override + public boolean canCreateBugReports() { + return true; + } + + @Override + public boolean canFixBugs() { + return false; + } + + @Override + public boolean canTestBugs() { + return true; + } +} diff --git a/src/main/java/org/lab/domain/user/User.java b/src/main/java/org/lab/domain/user/User.java new file mode 100644 index 0000000..c6977ad --- /dev/null +++ b/src/main/java/org/lab/domain/user/User.java @@ -0,0 +1,20 @@ +package org.lab.domain.user; + +import java.util.Objects; +import java.util.UUID; + +public record User(UUID id, String name, String email) { + + public User { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(email); + if (name.isBlank()) { + throw new IllegalArgumentException("Name cannot be blank"); + } + } + + public static User create(String name, String email) { + return new User(UUID.randomUUID(), name, email); + } +} 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..f0d7b1b --- /dev/null +++ b/src/main/java/org/lab/repository/BugReportRepository.java @@ -0,0 +1,22 @@ +package org.lab.repository; + +import java.util.Map; +import java.util.UUID; + +import org.lab.domain.project.BugReport; +import org.lab.domain.project.BugStatus; + +import static java.util.stream.Collectors.counting; +import static java.util.stream.Collectors.groupingBy; + +public class BugReportRepository extends InMemoryRepository { + + public BugReportRepository() { + super(BugReport::id); + } + + public Map getStatusStats(UUID projectId) { + return findAll(b -> b.projectId().equals(projectId)).stream() + .collect(groupingBy(BugReport::status, 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..09f0bd9 --- /dev/null +++ b/src/main/java/org/lab/repository/InMemoryRepository.java @@ -0,0 +1,40 @@ +package org.lab.repository; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; +import java.util.function.Predicate; + +public class InMemoryRepository implements Repository { + + private final ConcurrentMap storage = new ConcurrentHashMap<>(); + private final Function idExtractor; + + public InMemoryRepository(Function idExtractor) { + this.idExtractor = Objects.requireNonNull(idExtractor); + } + + @Override + public T save(T entity) { + storage.put(idExtractor.apply(entity), entity); + return entity; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAll(Predicate predicate) { + return storage.values().stream().filter(predicate).toList(); + } + + protected Optional findFirst(Predicate predicate) { + return findAll(predicate).stream().findFirst(); + } +} diff --git a/src/main/java/org/lab/repository/MembershipRepository.java b/src/main/java/org/lab/repository/MembershipRepository.java new file mode 100644 index 0000000..d4550ff --- /dev/null +++ b/src/main/java/org/lab/repository/MembershipRepository.java @@ -0,0 +1,38 @@ +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 org.lab.domain.user.ProjectMembership; +import org.lab.domain.user.Role; + +public class MembershipRepository { + + private final Map storage = new ConcurrentHashMap<>(); + + private static String key(UUID userId, UUID projectId) { + return userId + ":" + projectId; + } + + public ProjectMembership save(ProjectMembership membership) { + storage.put(key(membership.userId(), membership.projectId()), membership); + return membership; + } + + public Optional find(UUID userId, UUID projectId) { + return Optional.ofNullable(storage.get(key(userId, projectId))); + } + + public Optional getRole(UUID userId, UUID projectId) { + return find(userId, projectId).map(ProjectMembership::role); + } + + public List findByProjectId(UUID projectId) { + return storage.values().stream() + .filter(m -> m.isForProject(projectId)) + .toList(); + } +} 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..1176852 --- /dev/null +++ b/src/main/java/org/lab/repository/MilestoneRepository.java @@ -0,0 +1,22 @@ +package org.lab.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.lab.domain.project.Milestone; +import org.lab.domain.project.MilestoneStatus; + +public class MilestoneRepository extends InMemoryRepository { + + public MilestoneRepository() { + super(Milestone::id); + } + + public Optional findActiveByProjectId(UUID projectId) { + return findFirst(m -> m.projectId().equals(projectId) && m.status() == MilestoneStatus.ACTIVE); + } + + 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..3c8f7e2 --- /dev/null +++ b/src/main/java/org/lab/repository/ProjectRepository.java @@ -0,0 +1,10 @@ +package org.lab.repository; + +import org.lab.domain.project.Project; + +public class ProjectRepository extends InMemoryRepository { + + public ProjectRepository() { + super(Project::id); + } +} 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..da24acc --- /dev/null +++ b/src/main/java/org/lab/repository/Repository.java @@ -0,0 +1,16 @@ +package org.lab.repository; + +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(Predicate predicate); + +} 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..7b7257e --- /dev/null +++ b/src/main/java/org/lab/repository/TicketRepository.java @@ -0,0 +1,31 @@ +package org.lab.repository; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.lab.domain.project.Ticket; +import org.lab.domain.project.TicketStatus; + +import static java.util.stream.Collectors.counting; +import static java.util.stream.Collectors.groupingBy; + +public class TicketRepository extends InMemoryRepository { + + public TicketRepository() { + super(Ticket::id); + } + + public List findByMilestoneId(UUID milestoneId) { + return findAll(t -> t.milestoneId().equals(milestoneId)); + } + + public long countIncomplete(UUID milestoneId) { + return findByMilestoneId(milestoneId).stream().filter(t -> !t.isCompleted()).count(); + } + + public Map getStatusStats(UUID milestoneId) { + return findByMilestoneId(milestoneId).stream() + .collect(groupingBy(Ticket::status, 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..52297fe --- /dev/null +++ b/src/main/java/org/lab/repository/UserRepository.java @@ -0,0 +1,20 @@ +package org.lab.repository; + +import java.util.Optional; + +import org.lab.domain.user.User; + +public class UserRepository extends InMemoryRepository { + + public UserRepository() { + super(User::id); + } + + public Optional findByEmail(String email) { + return findFirst(u -> u.email().equalsIgnoreCase(email)); + } + + 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..88f6e56 --- /dev/null +++ b/src/main/java/org/lab/service/BugReportService.java @@ -0,0 +1,120 @@ +package org.lab.service; + +import java.util.Map; +import java.util.UUID; + +import org.lab.domain.common.Result; +import org.lab.domain.project.BugReport; +import org.lab.domain.project.BugStatus; +import org.lab.domain.user.Role; +import org.lab.repository.BugReportRepository; +import org.lab.repository.MembershipRepository; +import org.lab.repository.ProjectRepository; + +public class BugReportService { + + private final BugReportRepository bugReportRepository; + private final ProjectRepository projectRepository; + private final MembershipRepository membershipRepository; + + public BugReportService(BugReportRepository bugReportRepository, ProjectRepository projectRepository, + MembershipRepository membershipRepository) { + this.bugReportRepository = bugReportRepository; + this.projectRepository = projectRepository; + this.membershipRepository = membershipRepository; + } + + public Result createBugReport(String title, String description, UUID projectId, UUID userId) { + if (projectRepository.findById(projectId).isEmpty()) { + return Result.failure("Project is not found"); + } + + var role = membershipRepository.getRole(userId, projectId); + if (!role.map(Role::canCreateBugReports).orElse(false)) { + return Result.failure("Cannot create bug reports"); + } + + try { + var bug = BugReport.create(title, description, projectId, userId); + bugReportRepository.save(bug); + return Result.success(bug); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } + + public Result assignBug(UUID bugId, UUID assigneeId, UUID userId) { + var bug = bugReportRepository.findById(bugId); + if (bug.isEmpty()) { + return Result.failure("Bug is not found"); + } + + var role = membershipRepository.getRole(userId, bug.get().projectId()); + if (!role.map(Role::canManageTickets).orElse(false)) { + return Result.failure("Cannot assign bugs"); + } + var assigneeRole = membershipRepository.getRole(assigneeId, bug.get().projectId()); + if (!assigneeRole.map(Role::canFixBugs).orElse(false)) { + return Result.failure("Cannot fix bugs"); + } + var updated = bug.get().withAssignee(assigneeId); + bugReportRepository.save(updated); + return Result.success(updated); + } + + public Result markFixed(UUID bugId, UUID userId) { + var bug = bugReportRepository.findById(bugId); + if (bug.isEmpty()) { + return Result.failure("Bug is not found"); + } + + var role = membershipRepository.getRole(userId, bug.get().projectId()); + if (!role.map(Role::canFixBugs).orElse(false)) { + return Result.failure("Cannot fix bugs"); + } + if (bug.get().status() != BugStatus.NEW) { + return Result.failure("Incorrect bug status"); + } + var updated = bug.get().markFixed(); + bugReportRepository.save(updated); + return Result.success(updated); + } + + public Result markTested(UUID bugId, UUID userId) { + var bug = bugReportRepository.findById(bugId); + if (bug.isEmpty()) return Result.failure("Bug is not found"); + + var role = membershipRepository.getRole(userId, bug.get().projectId()); + if (!role.map(Role::canTestBugs).orElse(false)) { + return Result.failure("Cannot test bugs"); + } + if (bug.get().status() != BugStatus.FIXED) { + return Result.failure("Incorrect bug status"); + } + var updated = bug.get().markTested(); + bugReportRepository.save(updated); + return Result.success(updated); + } + + public Result closeBug(UUID bugId, UUID userId) { + var bug = bugReportRepository.findById(bugId); + if (bug.isEmpty()) { + return Result.failure("Bug is not found"); + } + + var role = membershipRepository.getRole(userId, bug.get().projectId()); + if (!role.map(Role::canTestBugs).orElse(false)) { + return Result.failure("Cannot close bugs"); + } + if (bug.get().status() != BugStatus.TESTED) { + return Result.failure("Incorrect bug status"); + } + var updated = bug.get().close(); + bugReportRepository.save(updated); + return Result.success(updated); + } + + public Map getBugStats(UUID projectId) { + return bugReportRepository.getStatusStats(projectId); + } +} 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..2c4a6f2 --- /dev/null +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -0,0 +1,99 @@ +package org.lab.service; + +import java.time.LocalDate; +import java.util.UUID; +import java.util.function.Function; + +import org.lab.domain.common.Result; +import org.lab.domain.project.Milestone; +import org.lab.domain.project.MilestoneStatus; +import org.lab.domain.user.Manager; +import org.lab.repository.MembershipRepository; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.ProjectRepository; +import org.lab.repository.TicketRepository; + +public class MilestoneService { + + private final MilestoneRepository milestoneRepository; + private final ProjectRepository projectRepository; + private final TicketRepository ticketRepository; + private final MembershipRepository membershipRepository; + + public MilestoneService( + MilestoneRepository milestoneRepository, + ProjectRepository projectRepository, + TicketRepository ticketRepository, + MembershipRepository membershipRepository + ) { + this.milestoneRepository = milestoneRepository; + this.projectRepository = projectRepository; + this.ticketRepository = ticketRepository; + this.membershipRepository = membershipRepository; + } + + public Result createMilestone(String name, UUID projectId, LocalDate start, LocalDate end, UUID userId) { + if (!isManager(userId, projectId)) { + return Result.failure("User cannot create milestone"); + } + if (projectRepository.findById(projectId).isEmpty()) { + return Result.failure("Project is not found"); + } + try { + var milestone = Milestone.create(name, projectId, start, end); + milestoneRepository.save(milestone); + return Result.success(milestone); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } + + public Result activateMilestone(UUID milestoneId, UUID userId) { + return withMilestone(milestoneId, userId, milestone -> { + if (milestoneRepository.hasActiveMilestone(milestone.projectId())) { + return Result.failure("Project already has an active milestone"); + } + if (milestone.status() != MilestoneStatus.OPEN) { + return Result.failure("Can activate only open milestone"); + } + var activated = milestone.activate(); + milestoneRepository.save(activated); + return Result.success(activated); + }); + } + + public Result closeMilestone(UUID milestoneId, UUID userId) { + return withMilestone(milestoneId, userId, milestone -> { + if (milestone.status() != MilestoneStatus.ACTIVE) { + return Result.failure("Can close only active milestone"); + } + long incomplete = ticketRepository.countIncomplete(milestoneId); + if (incomplete > 0) { + return Result.failure("Cannot close milestone: " + incomplete + " incomplete tickets found"); + } + var closed = milestone.close(); + milestoneRepository.save(closed); + return Result.success(closed); + }); + } + + private boolean isManager(UUID userId, UUID projectId) { + return membershipRepository.getRole(userId, projectId) + .map(r -> r instanceof Manager).orElse(false); + } + + private Result withMilestone( + UUID id, + UUID userId, + Function> action + ) { + var milestone = milestoneRepository.findById(id); + if (milestone.isEmpty()) { + return Result.failure("Milestone is not found"); + } + if (!isManager(userId, milestone.get().projectId())) { + return Result.failure("Cannot manage milestones"); + } + return action.apply(milestone.get()); + } +} 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..b4b76b2 --- /dev/null +++ b/src/main/java/org/lab/service/ProjectService.java @@ -0,0 +1,114 @@ +package org.lab.service; + +import java.util.List; +import java.util.UUID; +import java.util.function.Function; + +import org.lab.domain.common.Result; +import org.lab.domain.project.Project; +import org.lab.domain.user.Developer; +import org.lab.domain.user.Manager; +import org.lab.domain.user.ProjectMembership; +import org.lab.domain.user.TeamLead; +import org.lab.domain.user.Tester; +import org.lab.repository.MembershipRepository; +import org.lab.repository.ProjectRepository; +import org.lab.repository.UserRepository; + +public class ProjectService { + + private final ProjectRepository projectRepository; + private final UserRepository userRepository; + private final MembershipRepository membershipRepository; + + public ProjectService(ProjectRepository projectRepository, UserRepository userRepository, + MembershipRepository membershipRepository) { + this.projectRepository = projectRepository; + this.userRepository = userRepository; + this.membershipRepository = membershipRepository; + } + + public Result createProject(String name, String description, UUID creatorId) { + if (userRepository.findById(creatorId).isEmpty()) { + return Result.failure("User is not found"); + } + try { + var project = Project.create(name, description, creatorId); + projectRepository.save(project); + membershipRepository.save(ProjectMembership.of(creatorId, project.id(), new Manager())); + return Result.success(project); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } + + public Result assignTeamLead(UUID projectId, UUID teamLeadId, UUID currentUserId) { + return withManagerAccess(projectId, currentUserId, project -> { + if (userRepository.findById(teamLeadId).isEmpty()) { + return Result.failure("User is not found"); + } + if (project.managerId().equals(teamLeadId)) { + return Result.failure("Manager cannot be a team lead"); + } + var updated = project.withTeamLead(teamLeadId); + projectRepository.save(updated); + membershipRepository.save(ProjectMembership.of(teamLeadId, projectId, new TeamLead())); + return Result.success(updated); + }); + } + + public Result addDeveloper(UUID projectId, UUID developerId, UUID currentUserId) { + return withManagerAccess(projectId, currentUserId, project -> { + if (userRepository.findById(developerId).isEmpty()) { + return Result.failure("User is not found"); + } + if (project.managerId().equals(developerId)) { + return Result.failure("Manager cannot be a developer"); + } + if (project.developerIds().contains(developerId)) { + return Result.failure("User is already a developer"); + } + var updated = project.withDeveloper(developerId); + projectRepository.save(updated); + membershipRepository.save(ProjectMembership.of(developerId, projectId, new Developer())); + return Result.success(updated); + }); + } + + public Result addTester(UUID projectId, UUID testerId, UUID currentUserId) { + return withManagerAccess(projectId, currentUserId, project -> { + if (userRepository.findById(testerId).isEmpty()) { + return Result.failure("User is not found"); + } + if (project.managerId().equals(testerId)) { + return Result.failure("Manager cannot be a tester"); + } + var updated = project.withTester(testerId); + projectRepository.save(updated); + membershipRepository.save(ProjectMembership.of(testerId, projectId, new Tester())); + return Result.success(updated); + }); + } + + public List getProjectMembers(UUID projectId) { + return membershipRepository.findByProjectId(projectId); + } + + private Result withManagerAccess( + UUID projectId, + UUID currentUserId, + Function> action + ) { + var project = projectRepository.findById(projectId); + if (project.isEmpty()) { + return Result.failure("Project is not found"); + } + var role = membershipRepository.getRole(currentUserId, projectId); + boolean isManager = role.map(r -> r instanceof Manager).orElse(false); + + if (!isManager) { + return Result.failure("User is not permitted to access the operation"); + } + return action.apply(project.get()); + } +} 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..2babf61 --- /dev/null +++ b/src/main/java/org/lab/service/TicketService.java @@ -0,0 +1,103 @@ +package org.lab.service; + +import java.util.Map; +import java.util.UUID; + +import org.lab.domain.common.Result; +import org.lab.domain.project.Ticket; +import org.lab.domain.project.TicketStatus; +import org.lab.domain.user.Role; +import org.lab.repository.MembershipRepository; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.ProjectRepository; +import org.lab.repository.TicketRepository; + +public class TicketService { + + private final TicketRepository ticketRepository; + private final MilestoneRepository milestoneRepository; + private final ProjectRepository projectRepository; + private final MembershipRepository membershipRepository; + + public TicketService(TicketRepository ticketRepository, MilestoneRepository milestoneRepository, + ProjectRepository projectRepository, MembershipRepository membershipRepository) { + this.ticketRepository = ticketRepository; + this.milestoneRepository = milestoneRepository; + this.projectRepository = projectRepository; + this.membershipRepository = membershipRepository; + } + + public Result createTicket(String title, String description, UUID projectId, UUID milestoneId, UUID userId) { + var role = membershipRepository.getRole(userId, projectId); + if (!role.map(Role::canManageTickets).orElse(false)) { + return Result.failure("Cannot create tickets"); + } + var milestone = milestoneRepository.findById(milestoneId); + if (milestone.isEmpty() || !milestone.get().projectId().equals(projectId)) { + return Result.failure("Milestone is not found"); + } + if (!milestone.get().canAddTickets()) { + return Result.failure("Cannot add tickets to closed milestone"); + } + try { + var ticket = Ticket.create(title, description, projectId, milestoneId); + ticketRepository.save(ticket); + return Result.success(ticket); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } + + public Result assignTicket(UUID ticketId, UUID assigneeId, UUID userId) { + var ticket = ticketRepository.findById(ticketId); + if (ticket.isEmpty()) return Result.failure("Ticket is not found"); + + var role = membershipRepository.getRole(userId, ticket.get().projectId()); + if (!role.map(Role::canManageTickets).orElse(false)) { + return Result.failure("Cannot assign tickets"); + } + var project = projectRepository.findById(ticket.get().projectId()); + if (project.isEmpty() || !project.get().isDeveloper(assigneeId)) { + return Result.failure("User cannot be assigned to the ticket"); + } + var updated = ticket.get().withAssignee(assigneeId); + ticketRepository.save(updated); + return Result.success(updated); + } + + public Result acceptTicket(UUID ticketId, UUID userId) { + return updateTicketStatus(ticketId, userId, TicketStatus.NEW, Ticket::accept); + } + + public Result startWork(UUID ticketId, UUID userId) { + return updateTicketStatus(ticketId, userId, TicketStatus.ACCEPTED, Ticket::startWork); + } + + public Result completeTicket(UUID ticketId, UUID userId) { + return updateTicketStatus(ticketId, userId, TicketStatus.IN_PROGRESS, Ticket::complete); + } + + public Map getTicketStats(UUID milestoneId) { + return ticketRepository.getStatusStats(milestoneId); + } + + private Result updateTicketStatus(UUID ticketId, UUID userId, TicketStatus expected, + java.util.function.Function action) { + var ticket = ticketRepository.findById(ticketId); + if (ticket.isEmpty()) return Result.failure("Ticket is not found"); + + var role = membershipRepository.getRole(userId, ticket.get().projectId()); + if (!role.map(Role::canExecuteTickets).orElse(false)) { + return Result.failure("Cannot update status of tickets"); + } + if (!ticket.get().isAssignedTo(userId)) { + return Result.failure("Ticket assigned to another user"); + } + if (ticket.get().status() != expected) { + return Result.failure("Incorrect ticket status"); + } + var updated = action.apply(ticket.get()); + ticketRepository.save(updated); + return Result.success(updated); + } +} 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..d7f7c9c --- /dev/null +++ b/src/main/java/org/lab/service/UserService.java @@ -0,0 +1,34 @@ +package org.lab.service; + +import java.util.Optional; +import java.util.UUID; + +import org.lab.domain.common.Result; +import org.lab.domain.user.User; +import org.lab.repository.UserRepository; + +public class UserService { + + private final UserRepository userRepository; + + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public Result register(String name, String email) { + if (userRepository.existsByEmail(email)) { + return Result.failure("User with email " + email + " already exists"); + } + try { + var user = User.create(name, email); + userRepository.save(user); + return Result.success(user); + } catch (IllegalArgumentException e) { + return Result.failure(e.getMessage()); + } + } + + public Optional findById(UUID userId) { + return userRepository.findById(userId); + } +}