From 670afdca3c3d9da861b6d0198e15519b6573ef8b Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:10:19 +0000 Subject: [PATCH 01/15] add deadline --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4a80115..19aec56 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/TvkQWWs6) # Features of modern Java # Цели и задачи л/р: From ed28d19bbd9e4fa62e60b97ed5897fb7a71d036b Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 21:42:31 +0300 Subject: [PATCH 02/15] chore: setup java 25 as baseline --- README.md | 35 +++++++++++++++++++++++++++++------ build.gradle.kts | 22 ++++++++++++++++++++-- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 19aec56..ca7b732 100644 --- a/README.md +++ b/README.md @@ -1,10 +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 * строковые шаблоны)))))))))))))) * расширенные возможности стандартной библиотеки Java @@ -14,19 +17,30 @@ * и т.д. # Обязательное условие: + * Использование системы сборки Gradle * Код должен быть отлажен и протестирован # Дедлайн 24.12.2025 23:59 # Задание -Бизнес-логика для системы управления проектами. Система позволяет группе разработчиков управлять разработкой программных проектов. В ней определены следующие объекты: -* Проект. У каждого проекта есть определенная команда разработчиков, тестировщиков и один менеджер. Также к проекту может быть привязан тимлидер. У проекта определены различные майлстоуны. К каждому проекту могут быть привязаны сообщения об ошибках. -* Майлстоун. Одна из итераций цикла разработки проекта. Привязан к определенным датам. К майлстоунам привязаны определенные тикеты (задания). Майлстоун имеет определенный статус: открыт, активен или закрыт. Майлстоун может быть закрыт только когда все его тикеты выполнены. В каждый момент времени у проекта может быть только один майлстоун. -* Тикет. Определенное задание для разработчиков. Может быть выдано определенной группе разработчиков. Привязан к определенному проекту и майлстоуну. Имеет статус: новый, принятый, в процессе выполнения, выполнен. -* Сообщение об ошибке. Отчет о найденной ошибке в проекте. Привязан к определенному проекту. Имеет статус: новый, исправленный, протестированный, закрытый. + +Бизнес-логика для системы управления проектами. Система позволяет группе разработчиков управлять разработкой программных +проектов. В ней определены следующие объекты: + +* Проект. У каждого проекта есть определенная команда разработчиков, тестировщиков и один менеджер. Также к проекту + может быть привязан тимлидер. У проекта определены различные майлстоуны. К каждому проекту могут быть привязаны + сообщения об ошибках. +* Майлстоун. Одна из итераций цикла разработки проекта. Привязан к определенным датам. К майлстоунам привязаны + определенные тикеты (задания). Майлстоун имеет определенный статус: открыт, активен или закрыт. Майлстоун может быть + закрыт только когда все его тикеты выполнены. В каждый момент времени у проекта может быть только один майлстоун. +* Тикет. Определенное задание для разработчиков. Может быть выдано определенной группе разработчиков. Привязан к + определенному проекту и майлстоуну. Имеет статус: новый, принятый, в процессе выполнения, выполнен. +* Сообщение об ошибке. Отчет о найденной ошибке в проекте. Привязан к определенному проекту. Имеет статус: новый, + исправленный, протестированный, закрытый. В системе определены следующие роли для пользователей: + * менеджер; * тимлидер; * разработчик; @@ -34,6 +48,7 @@ Для каждого проекта у пользователя определена своя роль (если он участвует в разработке данного проекта). У всех пользователей системы есть возможность: + * зарегистрироваться; * просмотреть все проекты в которых они участвуют; * посмотреть список заданий, который был им выдан; @@ -41,22 +56,28 @@ * создать новый проект. Функции менеджера проекта: + * Управление пользователями: + 1. назначение тимлидера 2. добавление разработчика к проекту 3. добавление тестировщика к проекту * Управление майлстоунами: + 1. создание нового майлстоуна 2. изменение статуса майлстоуна * Управление тикетами + 1. создание нового тикета 2. привязка разработчика к тикету 3. проверка выполнения тикета Функции тимлидера: + * Управление тикетами + 1. создание нового тикета 2. привязка разработчика к тикету 3. проверка выполнения тикета @@ -64,11 +85,13 @@ * Выполнение тикетов Функции разработчика: + * Выполнение тикетов * Создание сообщений об ошибках * Исправление сообщений об ошибках Функции тестировщика: + * Тестирование проекта * Создание сообщений об ошибках -* Проверка исправления сообщений об ошибках \ No newline at end of file +* Проверка исправления сообщений об ошибках diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..7a33cb0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,14 @@ plugins { group = "org.lab" version = "1.0-SNAPSHOT" +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 + } +} + repositories { mavenCentral() } @@ -15,6 +23,16 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } -tasks.test { +tasks.withType { + options.compilerArgs.add("--enable-preview") + options.compilerArgs.add("-Xlint:preview") +} + +tasks.withType { useJUnitPlatform() -} \ No newline at end of file + jvmArgs("--enable-preview") +} + +tasks.withType { + jvmArgs("--enable-preview") +} From 8dde6dccda8e7957bc44e1267280ac5291cf2da9 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 21:42:44 +0300 Subject: [PATCH 03/15] feat: add domain models --- src/main/java/org/lab/Main.java | 1 - .../org/lab/domain/model/enums/BugStatus.java | 9 +++ .../domain/model/enums/MilestoneStatus.java | 8 +++ .../java/org/lab/domain/model/enums/Role.java | 9 +++ .../lab/domain/model/enums/TicketStatus.java | 9 +++ .../lab/domain/model/user/ProjectMember.java | 14 +++++ .../java/org/lab/domain/model/user/User.java | 11 ++++ .../lab/domain/model/workitems/BugReport.java | 18 ++++++ .../lab/domain/model/workitems/Milestone.java | 18 ++++++ .../lab/domain/model/workitems/Project.java | 57 +++++++++++++++++++ .../lab/domain/model/workitems/Ticket.java | 20 +++++++ .../lab/domain/model/workitems/WorkItem.java | 7 +++ 12 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/lab/domain/model/enums/BugStatus.java create mode 100644 src/main/java/org/lab/domain/model/enums/MilestoneStatus.java create mode 100644 src/main/java/org/lab/domain/model/enums/Role.java create mode 100644 src/main/java/org/lab/domain/model/enums/TicketStatus.java create mode 100644 src/main/java/org/lab/domain/model/user/ProjectMember.java create mode 100644 src/main/java/org/lab/domain/model/user/User.java create mode 100644 src/main/java/org/lab/domain/model/workitems/BugReport.java create mode 100644 src/main/java/org/lab/domain/model/workitems/Milestone.java create mode 100644 src/main/java/org/lab/domain/model/workitems/Project.java create mode 100644 src/main/java/org/lab/domain/model/workitems/Ticket.java create mode 100644 src/main/java/org/lab/domain/model/workitems/WorkItem.java diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index 22028ef..adaf245 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -1,4 +1,3 @@ void main() { IO.println("Hello and welcome!"); } - diff --git a/src/main/java/org/lab/domain/model/enums/BugStatus.java b/src/main/java/org/lab/domain/model/enums/BugStatus.java new file mode 100644 index 0000000..0b63a7d --- /dev/null +++ b/src/main/java/org/lab/domain/model/enums/BugStatus.java @@ -0,0 +1,9 @@ +package org.lab.domain.model.enums; + +public enum BugStatus { + NEW, + FIXED, + TESTED, + CLOSED, + ; +} diff --git a/src/main/java/org/lab/domain/model/enums/MilestoneStatus.java b/src/main/java/org/lab/domain/model/enums/MilestoneStatus.java new file mode 100644 index 0000000..bff6cb7 --- /dev/null +++ b/src/main/java/org/lab/domain/model/enums/MilestoneStatus.java @@ -0,0 +1,8 @@ +package org.lab.domain.model.enums; + +public enum MilestoneStatus { + OPEN, + ACTIVE, + CLOSED, + ; +} diff --git a/src/main/java/org/lab/domain/model/enums/Role.java b/src/main/java/org/lab/domain/model/enums/Role.java new file mode 100644 index 0000000..b89709d --- /dev/null +++ b/src/main/java/org/lab/domain/model/enums/Role.java @@ -0,0 +1,9 @@ +package org.lab.domain.model.enums; + +public enum Role { + MANAGER, + TEAM_LEAD, + DEVELOPER, + TESTER, + ; +} diff --git a/src/main/java/org/lab/domain/model/enums/TicketStatus.java b/src/main/java/org/lab/domain/model/enums/TicketStatus.java new file mode 100644 index 0000000..03e54d4 --- /dev/null +++ b/src/main/java/org/lab/domain/model/enums/TicketStatus.java @@ -0,0 +1,9 @@ +package org.lab.domain.model.enums; + +public enum TicketStatus { + OPEN, + ACCEPTED, + IN_PROGRESS, + DONE, + ; +} diff --git a/src/main/java/org/lab/domain/model/user/ProjectMember.java b/src/main/java/org/lab/domain/model/user/ProjectMember.java new file mode 100644 index 0000000..a6999bb --- /dev/null +++ b/src/main/java/org/lab/domain/model/user/ProjectMember.java @@ -0,0 +1,14 @@ +package org.lab.domain.model.user; + +import org.lab.domain.model.enums.Role; + +public record ProjectMember(User user, Role role, String projectId) { + + public boolean isManager() { + return role == Role.MANAGER; + } + + public boolean isTeamLead() { + return role == Role.TEAM_LEAD; + } +} diff --git a/src/main/java/org/lab/domain/model/user/User.java b/src/main/java/org/lab/domain/model/user/User.java new file mode 100644 index 0000000..11d59ff --- /dev/null +++ b/src/main/java/org/lab/domain/model/user/User.java @@ -0,0 +1,11 @@ +package org.lab.domain.model.user; + +import java.util.Objects; + +public record User(String id, String name) { + + public User { + Objects.requireNonNull(id, "User ID cannot be null"); + Objects.requireNonNull(name, "User Name cannot be null"); + } +} diff --git a/src/main/java/org/lab/domain/model/workitems/BugReport.java b/src/main/java/org/lab/domain/model/workitems/BugReport.java new file mode 100644 index 0000000..71d3db6 --- /dev/null +++ b/src/main/java/org/lab/domain/model/workitems/BugReport.java @@ -0,0 +1,18 @@ +package org.lab.domain.model.workitems; + +import org.lab.domain.model.enums.BugStatus; +import org.lab.domain.model.user.User; + +record BugReport( + String id, + String projectId, + String title, + String description, + BugStatus status, + User reporter +) implements WorkItem { + + public static BugReport createNew(String id, String projectId, String title, User reporter) { + return new BugReport(id, projectId, title, "", BugStatus.NEW, reporter); + } +} diff --git a/src/main/java/org/lab/domain/model/workitems/Milestone.java b/src/main/java/org/lab/domain/model/workitems/Milestone.java new file mode 100644 index 0000000..535767f --- /dev/null +++ b/src/main/java/org/lab/domain/model/workitems/Milestone.java @@ -0,0 +1,18 @@ +package org.lab.domain.model.workitems; + +import java.time.LocalDate; +import java.util.List; +import org.lab.domain.model.enums.MilestoneStatus; + +public record Milestone( + String id, + String name, + LocalDate startDate, + LocalDate endDate, + MilestoneStatus status, + List ticketIds +) { + public boolean isActive() { + return MilestoneStatus.ACTIVE == status; + } +} diff --git a/src/main/java/org/lab/domain/model/workitems/Project.java b/src/main/java/org/lab/domain/model/workitems/Project.java new file mode 100644 index 0000000..8f68dd5 --- /dev/null +++ b/src/main/java/org/lab/domain/model/workitems/Project.java @@ -0,0 +1,57 @@ +package org.lab.domain.model.workitems; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.lab.domain.model.enums.Role; +import org.lab.domain.model.user.ProjectMember; +import org.lab.domain.model.user.User; + +public record Project( + String id, + String name, + User manager, + User teamLead, + List developers, + List testers +) { + + public Project { + Objects.requireNonNull(id); + Objects.requireNonNull(name); + Objects.requireNonNull(manager); + + developers = developers != null ? List.copyOf(developers) : List.of(); + testers = testers != null ? List.copyOf(testers) : List.of(); + } + + public Optional getTeamLead() { + return Optional.ofNullable(teamLead); + } + + public List getAllMembers() { + var all = new ArrayList(); + all.add(manager); + getTeamLead().ifPresent(all::add); + all.addAll(developers); + all.addAll(testers); + return List.copyOf(all); + } + + public Optional getRoleFor(User user) { + if (manager.equals(user)) return Optional.of(Role.MANAGER); + + if (teamLead != null && teamLead.equals(user)) return Optional.of(Role.TEAM_LEAD); + + if (developers.contains(user)) return Optional.of(Role.DEVELOPER); + if (testers.contains(user)) return Optional.of(Role.TESTER); + + return Optional.empty(); + } + + public Optional getMember(User user) { + return getRoleFor(user) + .map(role -> new ProjectMember(user, role, this.id)); + } +} diff --git a/src/main/java/org/lab/domain/model/workitems/Ticket.java b/src/main/java/org/lab/domain/model/workitems/Ticket.java new file mode 100644 index 0000000..c698b54 --- /dev/null +++ b/src/main/java/org/lab/domain/model/workitems/Ticket.java @@ -0,0 +1,20 @@ +package org.lab.domain.model.workitems; + +import java.util.List; +import org.lab.domain.model.enums.TicketStatus; +import org.lab.domain.model.user.User; + +record Ticket( + String id, + String projectId, + String milestoneId, + String title, + String description, + TicketStatus status, + List assignees +) implements WorkItem { + + public static Ticket newTicket(String id, String projectId, String milestoneId, String title) { + return new Ticket(id, projectId, milestoneId, title, "", TicketStatus.OPEN, List.of()); + } +} diff --git a/src/main/java/org/lab/domain/model/workitems/WorkItem.java b/src/main/java/org/lab/domain/model/workitems/WorkItem.java new file mode 100644 index 0000000..dc572fe --- /dev/null +++ b/src/main/java/org/lab/domain/model/workitems/WorkItem.java @@ -0,0 +1,7 @@ +package org.lab.domain.model.workitems; + +public sealed interface WorkItem permits Ticket, BugReport { + String id(); + String projectId(); + String title(); +} From a145cc87a214157b8dd9196d811c9c16fbcd8e9f Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 22:27:26 +0300 Subject: [PATCH 04/15] feat: add main ports and adapters --- .../lab/domain/model/workitems/BugReport.java | 2 +- .../lab/domain/model/workitems/Ticket.java | 2 +- .../domain/port/ProjectRepositoryPort.java | 12 +++++ .../lab/domain/port/UserRepositoryPort.java | 10 ++++ .../domain/port/WorkItemRepositoryPort.java | 17 +++++++ .../InMemoryProjectRepositoryAdapter.java | 31 +++++++++++++ .../InMemoryUserRepositoryAdapter.java | 22 +++++++++ .../InMemoryWorkItemRepositoryAdapter.java | 46 +++++++++++++++++++ 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/lab/domain/port/ProjectRepositoryPort.java create mode 100644 src/main/java/org/lab/domain/port/UserRepositoryPort.java create mode 100644 src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java create mode 100644 src/main/java/org/lab/infrastructure/adapter/InMemoryProjectRepositoryAdapter.java create mode 100644 src/main/java/org/lab/infrastructure/adapter/InMemoryUserRepositoryAdapter.java create mode 100644 src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java diff --git a/src/main/java/org/lab/domain/model/workitems/BugReport.java b/src/main/java/org/lab/domain/model/workitems/BugReport.java index 71d3db6..2f531c7 100644 --- a/src/main/java/org/lab/domain/model/workitems/BugReport.java +++ b/src/main/java/org/lab/domain/model/workitems/BugReport.java @@ -3,7 +3,7 @@ import org.lab.domain.model.enums.BugStatus; import org.lab.domain.model.user.User; -record BugReport( +public record BugReport( String id, String projectId, String title, diff --git a/src/main/java/org/lab/domain/model/workitems/Ticket.java b/src/main/java/org/lab/domain/model/workitems/Ticket.java index c698b54..394059f 100644 --- a/src/main/java/org/lab/domain/model/workitems/Ticket.java +++ b/src/main/java/org/lab/domain/model/workitems/Ticket.java @@ -4,7 +4,7 @@ import org.lab.domain.model.enums.TicketStatus; import org.lab.domain.model.user.User; -record Ticket( +public record Ticket( String id, String projectId, String milestoneId, diff --git a/src/main/java/org/lab/domain/port/ProjectRepositoryPort.java b/src/main/java/org/lab/domain/port/ProjectRepositoryPort.java new file mode 100644 index 0000000..77dbddd --- /dev/null +++ b/src/main/java/org/lab/domain/port/ProjectRepositoryPort.java @@ -0,0 +1,12 @@ +package org.lab.domain.port; + +import java.util.List; +import java.util.Optional; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.Project; + +public interface ProjectRepositoryPort { + Project save(Project project); + Optional findById(String id); + List findAllByMember(User user); +} diff --git a/src/main/java/org/lab/domain/port/UserRepositoryPort.java b/src/main/java/org/lab/domain/port/UserRepositoryPort.java new file mode 100644 index 0000000..a701034 --- /dev/null +++ b/src/main/java/org/lab/domain/port/UserRepositoryPort.java @@ -0,0 +1,10 @@ +package org.lab.domain.port; + +import java.util.Optional; +import org.lab.domain.model.user.User; + +public interface UserRepositoryPort { + User save(User user); + + Optional findById(String id); +} diff --git a/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java b/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java new file mode 100644 index 0000000..8e3d099 --- /dev/null +++ b/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java @@ -0,0 +1,17 @@ +package org.lab.domain.port; + +import java.util.List; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.model.workitems.WorkItem; + +public interface WorkItemRepositoryPort { + WorkItem save(WorkItem item); + + List findTicketsByAssignee(User user); + + List findActiveBugsByProject(String projectId); + + List findAllByProjectId(String projectId); +} diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryProjectRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryProjectRepositoryAdapter.java new file mode 100644 index 0000000..97b5b49 --- /dev/null +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryProjectRepositoryAdapter.java @@ -0,0 +1,31 @@ +package org.lab.infrastructure.adapter; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.port.ProjectRepositoryPort; + +public class InMemoryProjectRepositoryAdapter implements ProjectRepositoryPort { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public Project save(Project project) { + storage.put(project.id(), project); + return project; + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAllByMember(User user) { + return storage.values().stream() + .filter(proj -> proj.getRoleFor(user).isPresent()) + .toList(); + } +} diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryUserRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryUserRepositoryAdapter.java new file mode 100644 index 0000000..07b0d08 --- /dev/null +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryUserRepositoryAdapter.java @@ -0,0 +1,22 @@ +package org.lab.infrastructure.adapter; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.lab.domain.model.user.User; +import org.lab.domain.port.UserRepositoryPort; + +public class InMemoryUserRepositoryAdapter implements UserRepositoryPort { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public User save(User user) { + storage.put(user.id(), user); + return user; + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(storage.get(id)); + } +} diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java new file mode 100644 index 0000000..263b0f0 --- /dev/null +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java @@ -0,0 +1,46 @@ +package org.lab.infrastructure.adapter; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.lab.domain.model.enums.BugStatus; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.model.workitems.WorkItem; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class InMemoryWorkItemRepositoryAdapter implements WorkItemRepositoryPort { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public WorkItem save(WorkItem item) { + storage.put(item.id(), item); + return item; + } + + @Override + public List findTicketsByAssignee(User user) { + return storage.values().stream() + .filter(item -> item instanceof Ticket ticket && ticket.assignees().contains(user)) + .map(item -> (Ticket) item) + .toList(); + } + + @Override + public List findActiveBugsByProject(String projectId) { + return storage.values().stream() + .filter(item -> item instanceof BugReport bug + && bug.projectId().equals(projectId)) + .map(item -> (BugReport) item) + .filter(bug -> bug.status() != BugStatus.CLOSED) + .toList(); + } + + @Override + public List findAllByProjectId(String projectId) { + return storage.values().stream() + .filter(item -> item.projectId().equals(projectId)) + .toList(); + } +} From 5736fe94cf0a92199f152820723b74e0301264d7 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 22:27:42 +0300 Subject: [PATCH 05/15] feat: add UserService --- .../lab/domain/application/UserService.java | 19 +++++ .../application/impl/UserServiceImpl.java | 72 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/main/java/org/lab/domain/application/UserService.java create mode 100644 src/main/java/org/lab/domain/application/impl/UserServiceImpl.java diff --git a/src/main/java/org/lab/domain/application/UserService.java b/src/main/java/org/lab/domain/application/UserService.java new file mode 100644 index 0000000..2e835af --- /dev/null +++ b/src/main/java/org/lab/domain/application/UserService.java @@ -0,0 +1,19 @@ +package org.lab.domain.application; + +import java.util.List; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; + +public interface UserService { + User registerUser(String name); + + Project createProject(User creator, String projectName); + + List getMyProjects(User user); + + List getMyAssignments(User user); + + List getBugsToFix(User user); +} diff --git a/src/main/java/org/lab/domain/application/impl/UserServiceImpl.java b/src/main/java/org/lab/domain/application/impl/UserServiceImpl.java new file mode 100644 index 0000000..f63134b --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/UserServiceImpl.java @@ -0,0 +1,72 @@ +package org.lab.domain.application.impl; + +import java.util.List; +import java.util.UUID; +import org.lab.domain.application.UserService; +import org.lab.domain.model.enums.Role; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.domain.port.UserRepositoryPort; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class UserServiceImpl implements UserService { + + private final UserRepositoryPort userRepository; + private final ProjectRepositoryPort projectRepository; + private final WorkItemRepositoryPort workItemRepository; + + + public UserServiceImpl( + UserRepositoryPort userRepository, + ProjectRepositoryPort projectRepository, + WorkItemRepositoryPort workItemRepository + ) { + this.userRepository = userRepository; + this.projectRepository = projectRepository; + this.workItemRepository = workItemRepository; + } + + @Override + public User registerUser(String name) { + var user = new User(UUID.randomUUID().toString(), name); + return userRepository.save(user); + } + + @Override + public Project createProject(User creator, String projectName) { + var project = new Project( + UUID.randomUUID().toString(), + projectName, + creator, + null, + List.of(), + List.of() + ); + return projectRepository.save(project); + } + + @Override + public List getMyProjects(User user) { + return projectRepository.findAllByMember(user); + } + + @Override + public List getMyAssignments(User user) { + return workItemRepository.findTicketsByAssignee(user); + } + + @Override + public List getBugsToFix(User user) { + List projects = projectRepository.findAllByMember(user); + + return projects.stream() + .filter(proj -> proj.getRoleFor(user) + .map(role -> role == Role.DEVELOPER || role == Role.TEAM_LEAD) + .orElse(false)) + .flatMap(proj -> workItemRepository.findActiveBugsByProject(proj.id()).stream()) + .toList(); + } +} From 0c406f2de717669d60390bc7304eaafa26806d4d Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 22:30:22 +0300 Subject: [PATCH 06/15] feat: add milestone port and adapter --- .../domain/port/MilestoneRepositoryPort.java | 13 +++++++++ .../InMemoryMilestoneRepositoryAdapter.java | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/main/java/org/lab/domain/port/MilestoneRepositoryPort.java create mode 100644 src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java diff --git a/src/main/java/org/lab/domain/port/MilestoneRepositoryPort.java b/src/main/java/org/lab/domain/port/MilestoneRepositoryPort.java new file mode 100644 index 0000000..884e71e --- /dev/null +++ b/src/main/java/org/lab/domain/port/MilestoneRepositoryPort.java @@ -0,0 +1,13 @@ +package org.lab.domain.port; + +import java.util.List; +import java.util.Optional; +import org.lab.domain.model.workitems.Milestone; + +public interface MilestoneRepositoryPort { + Milestone save(Milestone milestone); + + Optional findById(String id); + + List findAllByProjectId(String projectId); +} diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java new file mode 100644 index 0000000..735d211 --- /dev/null +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java @@ -0,0 +1,28 @@ +package org.lab.infrastructure.adapter; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.lab.domain.model.workitems.Milestone; +import org.lab.domain.port.MilestoneRepositoryPort; + +public class InMemoryMilestoneRepositoryAdapter implements MilestoneRepositoryPort { + private final Map storage = new ConcurrentHashMap<>(); + + @Override + public Milestone save(Milestone milestone) { + storage.put(milestone.id(), milestone); + return milestone; + } + + @Override + public Optional findById(String id) { + return Optional.ofNullable(storage.get(id)); + } + + @Override + public List findAllByProjectId(String projectId) { + return List.of(); + } +} From 561939e49f19637153d38cf3fe658ec877e7d456 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 22:56:58 +0300 Subject: [PATCH 07/15] feat: quality gate service with structured concurrency --- .../application/QualityGateService.java | 9 ++++ .../impl/QualityGateServiceImpl.java | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/main/java/org/lab/domain/application/QualityGateService.java create mode 100644 src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java diff --git a/src/main/java/org/lab/domain/application/QualityGateService.java b/src/main/java/org/lab/domain/application/QualityGateService.java new file mode 100644 index 0000000..802127e --- /dev/null +++ b/src/main/java/org/lab/domain/application/QualityGateService.java @@ -0,0 +1,9 @@ +package org.lab.domain.application; + +public interface QualityGateService { + + record ComplianceResult(boolean passed, String message) { + } + + ComplianceResult checkTicketClosed(String ticketId); +} diff --git a/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java new file mode 100644 index 0000000..1dc7b1a --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java @@ -0,0 +1,51 @@ +package org.lab.domain.application.impl; + +import java.time.Duration; +import java.util.concurrent.StructuredTaskScope; +import java.util.concurrent.StructuredTaskScope.Subtask; +import org.lab.domain.application.QualityGateService; + +@SuppressWarnings("preview") +public class QualityGateServiceImpl implements QualityGateService { + + @Override + public ComplianceResult checkTicketClosed(String ticketId) { + + try (var scope = StructuredTaskScope.open( + StructuredTaskScope.Joiner.allSuccessfulOrThrow(), + config -> config.withTimeout(Duration.ofSeconds(5)) + )) { + + Subtask testsTask = scope.fork(() -> checkCiTests(ticketId)); + Subtask docsTask = scope.fork(() -> checkDocumentation(ticketId)); + Subtask coverageTask = scope.fork(() -> checkCodeCoverage(ticketId)); + + boolean allPassed = testsTask.get() && docsTask.get() && coverageTask.get(); + + if (allPassed) { + return new ComplianceResult(true, "All Quality Gates passed."); + } else { + return new ComplianceResult(false, "Verification failed logic check."); + } + } catch (StructuredTaskScope.TimeoutException e) { + return new ComplianceResult(false, "Quality check timed out (2s limit)."); + } catch (Exception e) { + return new ComplianceResult(false, "System Error: " + e.getMessage()); + } + } + + private boolean checkCiTests(String ticketId) throws InterruptedException { + Thread.sleep(100); + return true; + } + + private boolean checkDocumentation(String ticketId) throws InterruptedException { + Thread.sleep(150); + return true; + } + + private boolean checkCodeCoverage(String ticketId) throws InterruptedException { + Thread.sleep(50); + return true; + } +} From 22d00fd2d2d210ef95ed6965db12807a63c6c185 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 23:06:24 +0300 Subject: [PATCH 08/15] fix: add projectId to milestone --- .../java/org/lab/domain/model/workitems/Milestone.java | 10 ++++++++++ .../adapter/InMemoryMilestoneRepositoryAdapter.java | 4 +++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/lab/domain/model/workitems/Milestone.java b/src/main/java/org/lab/domain/model/workitems/Milestone.java index 535767f..bef4c6c 100644 --- a/src/main/java/org/lab/domain/model/workitems/Milestone.java +++ b/src/main/java/org/lab/domain/model/workitems/Milestone.java @@ -2,16 +2,26 @@ import java.time.LocalDate; import java.util.List; +import java.util.Objects; import org.lab.domain.model.enums.MilestoneStatus; public record Milestone( String id, + String projectId, String name, LocalDate startDate, LocalDate endDate, MilestoneStatus status, List ticketIds ) { + public Milestone { + Objects.requireNonNull(id, "ID cannot be null"); + Objects.requireNonNull(projectId, "Project ID cannot be null"); + Objects.requireNonNull(name, "Name cannot be null"); + + ticketIds = ticketIds != null ? List.copyOf(ticketIds) : List.of(); + } + public boolean isActive() { return MilestoneStatus.ACTIVE == status; } diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java index 735d211..fba44f1 100644 --- a/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryMilestoneRepositoryAdapter.java @@ -23,6 +23,8 @@ public Optional findById(String id) { @Override public List findAllByProjectId(String projectId) { - return List.of(); + return storage.values().stream() + .filter(m -> m.projectId().equals(projectId)) + .toList(); } } From 3b227226a1d36d70e8d0b0be506c90a247917076 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 23:28:44 +0300 Subject: [PATCH 09/15] feat: add manager service --- .../domain/application/ManagerService.java | 28 ++ .../application/QualityGateService.java | 2 +- .../application/impl/ManagerServiceImpl.java | 277 ++++++++++++++++++ .../impl/QualityGateServiceImpl.java | 2 +- .../lab/domain/model/enums/TicketStatus.java | 1 + .../domain/port/WorkItemRepositoryPort.java | 3 + .../InMemoryWorkItemRepositoryAdapter.java | 6 + 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/lab/domain/application/ManagerService.java create mode 100644 src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java diff --git a/src/main/java/org/lab/domain/application/ManagerService.java b/src/main/java/org/lab/domain/application/ManagerService.java new file mode 100644 index 0000000..5b015ba --- /dev/null +++ b/src/main/java/org/lab/domain/application/ManagerService.java @@ -0,0 +1,28 @@ +package org.lab.domain.application; + +import org.lab.domain.model.enums.MilestoneStatus; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.Milestone; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; + +public interface ManagerService { + // team management + Project assignTeamLead(User manager, String projectId, User newTeamLead); + + Project addDeveloper(User manager, String projectId, User newDeveloper); + + Project addTester(User manager, String projectId, User newTester); + + // milestone management + Milestone createMilestone(User manager, String projectId, String name); + + Milestone updateMilestoneStatus(User manager, String milestoneId, MilestoneStatus newStatus); + + // ticket management + Ticket createTicket(User manager, String projectId, String title, String milestoneId); + + Ticket assignTicket(User manager, String ticketId, User developer); + + Ticket verifyTicket(User manager, String ticketId, boolean approved); +} diff --git a/src/main/java/org/lab/domain/application/QualityGateService.java b/src/main/java/org/lab/domain/application/QualityGateService.java index 802127e..00c8e0c 100644 --- a/src/main/java/org/lab/domain/application/QualityGateService.java +++ b/src/main/java/org/lab/domain/application/QualityGateService.java @@ -5,5 +5,5 @@ public interface QualityGateService { record ComplianceResult(boolean passed, String message) { } - ComplianceResult checkTicketClosed(String ticketId); + ComplianceResult checkTicketReadyToClose(String ticketId); } diff --git a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java new file mode 100644 index 0000000..68fc9ee --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java @@ -0,0 +1,277 @@ +package org.lab.domain.application.impl; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.StructuredTaskScope; +import java.util.concurrent.StructuredTaskScope.Joiner; +import java.util.concurrent.StructuredTaskScope.Subtask; +import java.util.stream.Stream; +import org.lab.domain.application.ManagerService; +import org.lab.domain.application.QualityGateService; +import org.lab.domain.model.enums.MilestoneStatus; +import org.lab.domain.model.enums.Role; +import org.lab.domain.model.enums.TicketStatus; +import org.lab.domain.model.user.ProjectMember; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Milestone; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.model.workitems.WorkItem; +import org.lab.domain.port.MilestoneRepositoryPort; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class ManagerServiceImpl implements ManagerService { + + private final ProjectRepositoryPort projectRepo; + private final MilestoneRepositoryPort milestoneRepo; + private final WorkItemRepositoryPort workItemRepo; + private final QualityGateService qualityGateService; + + public ManagerServiceImpl(ProjectRepositoryPort projectRepo, MilestoneRepositoryPort milestoneRepo, WorkItemRepositoryPort workItemRepo, QualityGateService qualityGateService) { + this.projectRepo = projectRepo; + this.milestoneRepo = milestoneRepo; + this.workItemRepo = workItemRepo; + this.qualityGateService = qualityGateService; + } + + @Override + public Project assignTeamLead(User manager, String projectId, User newTeamLead) { + var currentProject = getProjectIfManager(manager, projectId); + + var newProject = new Project( + currentProject.id(), + currentProject.name(), + currentProject.manager(), + newTeamLead, + currentProject.developers(), + currentProject.testers() + ); + + return projectRepo.save(newProject); + } + + @Override + public Project addDeveloper(User manager, String projectId, User newDeveloper) { + var currentProject = getProjectIfManager(manager, projectId); + + List newDevList = Stream.concat( + currentProject.developers().stream(), + Stream.of(newDeveloper) + ).distinct().toList(); + + var newProject = new Project( + currentProject.id(), + currentProject.name(), + currentProject.manager(), + currentProject.teamLead(), + newDevList, + currentProject.testers() + ); + + return projectRepo.save(newProject); + } + + @Override + public Project addTester(User manager, String projectId, User newTester) { + var currentProject = getProjectIfManager(manager, projectId); + + List newTesterList = Stream.concat( + currentProject.testers().stream(), + Stream.of(newTester) + ).distinct().toList(); + + var newProject = new Project( + currentProject.id(), + currentProject.name(), + currentProject.manager(), + currentProject.teamLead(), + currentProject.developers(), + newTesterList + ); + + return projectRepo.save(newProject); + } + + @Override + public Milestone createMilestone(User manager, String projectId, String name) { + getProjectIfManager(manager, projectId); + + var milestone = new Milestone( + UUID.randomUUID().toString(), + projectId, + name, + LocalDate.now(), + LocalDate.now().plusWeeks(2), + MilestoneStatus.OPEN, + List.of() + ); + + return milestoneRepo.save(milestone); + } + + @Override + public Milestone updateMilestoneStatus(User manager, String milestoneId, MilestoneStatus newStatus) { + var milestone = milestoneRepo.findById(milestoneId) + .orElseThrow(() -> new IllegalArgumentException("Milestone not found")); + + getProjectIfManager(manager, milestone.projectId()); + + if (newStatus == MilestoneStatus.ACTIVE) { + boolean hasActive = milestoneRepo.findAllByProjectId(milestone.projectId()).stream() + .anyMatch(m -> !m.id().equals(milestoneId) && m.isActive()); + + if (hasActive) { + throw new IllegalStateException("Project already has an ACTIVE milestone. Close it first."); + } + } + + var updated = new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + newStatus, + milestone.ticketIds() + ); + return milestoneRepo.save(updated); + } + + @Override + @SuppressWarnings("preview") + public Ticket createTicket(User manager, String projectId, String title, String milestoneId) { + getProjectIfManager(manager, projectId); + + var ticket = Ticket.newTicket( + UUID.randomUUID().toString(), + projectId, + milestoneId, + title + ); + + var milestone = milestoneRepo.findById(milestoneId).orElseThrow( + () -> new IllegalArgumentException("Milestone not found") + ); + + if (!milestone.projectId().equals(projectId)) { + throw new IllegalArgumentException("Milestone belongs to different project"); + } + + var newTicketIds = Stream.concat( + milestone.ticketIds().stream(), + Stream.of(ticket.id()) + ).distinct().toList(); + + var updatedMilestone = new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + milestone.status(), + newTicketIds + ); + + try (var scope = StructuredTaskScope.open( + Joiner.allSuccessfulOrThrow(), + config -> config.withTimeout(Duration.ofSeconds(5)) + )) { + Subtask ticketSaveTask = scope.fork(() -> workItemRepo.save(ticket)); + scope.fork(() -> milestoneRepo.save(updatedMilestone)); + + scope.join(); + return (Ticket) ticketSaveTask.get(); + } catch (Exception e) { + // имаджинируем компенсирующие транзакции + throw new RuntimeException("Failed to create ticket transactionally: " + e.getMessage(), e); + } + } + + @Override + public Ticket assignTicket(User manager, String ticketId, User developer) { + Ticket ticket = findTicket(ticketId); + Project project = getProjectIfManager(manager, ticket.projectId()); + + boolean isDev = project.getMember(developer) + .map(m -> Role.DEVELOPER == m.role()) + .orElse(false); + + if (!isDev) { + throw new IllegalArgumentException("User " + developer.name() + " is not a Developer on project " + project.name()); + } + + List newAssignees = Stream.concat( + ticket.assignees().stream(), + Stream.of(developer) + ).distinct().toList(); + + var updatedTicket = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + TicketStatus.ACCEPTED, + newAssignees + ); + + return (Ticket) workItemRepo.save(updatedTicket); + } + + @Override + public Ticket verifyTicket(User manager, String ticketId, boolean approved) { + Ticket ticket = findTicket(ticketId); + getProjectIfManager(manager, ticket.projectId()); + + if (approved && ticket.status() == TicketStatus.DONE) { + var compliance = qualityGateService.checkTicketReadyToClose(ticketId); + if (!compliance.passed()) { + throw new IllegalStateException("Quality Gates Failed: " + compliance.message()); + } + } + + TicketStatus nextStatus = switch (ticket.status()) { + case DONE -> approved ? TicketStatus.DONE : TicketStatus.IN_PROGRESS; + case OPEN, ACCEPTED, IN_PROGRESS -> + throw new IllegalStateException("Ticket is not in DONE state, cannot verify."); + case CLOSED -> throw new IllegalStateException("Ticket is already CLOSED."); + }; + + var updatedTicket = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updatedTicket); + } + + private Project getProjectIfManager(User user, String projectId) { + Project project = projectRepo.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found: " + projectId)); + + project.getMember(user) + .filter(ProjectMember::isManager) + .orElseThrow(() -> new SecurityException("User " + user.name() + " is not a Manager of this project")); + + return project; + } + + private Ticket findTicket(String ticketId) { + return workItemRepo.findById(ticketId) + .map(w -> switch (w) { + case Ticket t -> t; + case BugReport _ -> + throw new IllegalArgumentException("ID " + ticketId + " refers to a Bug, not a Ticket"); + }) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticketId)); + } +} diff --git a/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java index 1dc7b1a..e969dac 100644 --- a/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java @@ -9,7 +9,7 @@ public class QualityGateServiceImpl implements QualityGateService { @Override - public ComplianceResult checkTicketClosed(String ticketId) { + public ComplianceResult checkTicketReadyToClose(String ticketId) { try (var scope = StructuredTaskScope.open( StructuredTaskScope.Joiner.allSuccessfulOrThrow(), diff --git a/src/main/java/org/lab/domain/model/enums/TicketStatus.java b/src/main/java/org/lab/domain/model/enums/TicketStatus.java index 03e54d4..d800a40 100644 --- a/src/main/java/org/lab/domain/model/enums/TicketStatus.java +++ b/src/main/java/org/lab/domain/model/enums/TicketStatus.java @@ -5,5 +5,6 @@ public enum TicketStatus { ACCEPTED, IN_PROGRESS, DONE, + CLOSED, // checked by manager ; } diff --git a/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java b/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java index 8e3d099..21c964e 100644 --- a/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java +++ b/src/main/java/org/lab/domain/port/WorkItemRepositoryPort.java @@ -1,6 +1,7 @@ package org.lab.domain.port; import java.util.List; +import java.util.Optional; import org.lab.domain.model.user.User; import org.lab.domain.model.workitems.BugReport; import org.lab.domain.model.workitems.Ticket; @@ -9,6 +10,8 @@ public interface WorkItemRepositoryPort { WorkItem save(WorkItem item); + Optional findById(String id); + List findTicketsByAssignee(User user); List findActiveBugsByProject(String projectId); diff --git a/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java b/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java index 263b0f0..908cc7c 100644 --- a/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java +++ b/src/main/java/org/lab/infrastructure/adapter/InMemoryWorkItemRepositoryAdapter.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import org.lab.domain.model.enums.BugStatus; import org.lab.domain.model.user.User; @@ -19,6 +20,11 @@ public WorkItem save(WorkItem item) { return item; } + @Override + public Optional findById(String id) { + return Optional.ofNullable(storage.get(id)); + } + @Override public List findTicketsByAssignee(User user) { return storage.values().stream() From a23d91e54fdf77bcf7f9a7c63d2bc5c4becafdf0 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 23:44:07 +0300 Subject: [PATCH 10/15] feat: add teamlead service --- .../domain/application/TeamLeadService.java | 18 ++ .../application/impl/ManagerServiceImpl.java | 9 +- .../application/impl/TeamLeadServiceImpl.java | 236 ++++++++++++++++++ .../lab/domain/model/enums/TicketStatus.java | 2 +- .../lab/domain/model/workitems/Ticket.java | 2 +- 5 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/lab/domain/application/TeamLeadService.java create mode 100644 src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java diff --git a/src/main/java/org/lab/domain/application/TeamLeadService.java b/src/main/java/org/lab/domain/application/TeamLeadService.java new file mode 100644 index 0000000..a344daf --- /dev/null +++ b/src/main/java/org/lab/domain/application/TeamLeadService.java @@ -0,0 +1,18 @@ +package org.lab.domain.application; + +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.Ticket; + +public interface TeamLeadService { + // management + Ticket createTicket(User lead, String projectId, String title, String milestoneId); + + Ticket assignTicket(User lead, String ticketId, User developer); + + Ticket verifyTicket(User lead, String ticketId, boolean approved); + + // execution + Ticket startProgress(User lead, String ticketId); + + Ticket resolveTicket(User lead, String ticketId); +} diff --git a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java index 68fc9ee..16a5914 100644 --- a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java @@ -31,7 +31,12 @@ public class ManagerServiceImpl implements ManagerService { private final WorkItemRepositoryPort workItemRepo; private final QualityGateService qualityGateService; - public ManagerServiceImpl(ProjectRepositoryPort projectRepo, MilestoneRepositoryPort milestoneRepo, WorkItemRepositoryPort workItemRepo, QualityGateService qualityGateService) { + public ManagerServiceImpl( + ProjectRepositoryPort projectRepo, + MilestoneRepositoryPort milestoneRepo, + WorkItemRepositoryPort workItemRepo, + QualityGateService qualityGateService + ) { this.projectRepo = projectRepo; this.milestoneRepo = milestoneRepo; this.workItemRepo = workItemRepo; @@ -236,7 +241,7 @@ public Ticket verifyTicket(User manager, String ticketId, boolean approved) { TicketStatus nextStatus = switch (ticket.status()) { case DONE -> approved ? TicketStatus.DONE : TicketStatus.IN_PROGRESS; - case OPEN, ACCEPTED, IN_PROGRESS -> + case NEW, ACCEPTED, IN_PROGRESS -> throw new IllegalStateException("Ticket is not in DONE state, cannot verify."); case CLOSED -> throw new IllegalStateException("Ticket is already CLOSED."); }; diff --git a/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java b/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java new file mode 100644 index 0000000..12adbed --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java @@ -0,0 +1,236 @@ +package org.lab.domain.application.impl; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.StructuredTaskScope; +import java.util.concurrent.StructuredTaskScope.Joiner; +import java.util.concurrent.StructuredTaskScope.Subtask; +import java.util.stream.Stream; +import org.lab.domain.application.QualityGateService; +import org.lab.domain.application.TeamLeadService; +import org.lab.domain.model.enums.Role; +import org.lab.domain.model.enums.TicketStatus; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Milestone; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.model.workitems.WorkItem; +import org.lab.domain.port.MilestoneRepositoryPort; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class TeamLeadServiceImpl implements TeamLeadService { + + private final ProjectRepositoryPort projectRepo; + private final MilestoneRepositoryPort milestoneRepo; + private final WorkItemRepositoryPort workItemRepo; + private final QualityGateService qualityGateService; + + public TeamLeadServiceImpl( + ProjectRepositoryPort projectRepo, + MilestoneRepositoryPort milestoneRepo, + WorkItemRepositoryPort workItemRepo, + QualityGateService qualityGateService + ) { + this.projectRepo = projectRepo; + this.milestoneRepo = milestoneRepo; + this.workItemRepo = workItemRepo; + this.qualityGateService = qualityGateService; + } + + @Override + @SuppressWarnings("preview") + public Ticket createTicket(User lead, String projectId, String title, String milestoneId) { + getProjectIfTeamLead(lead, projectId); + + var ticket = Ticket.newTicket( + UUID.randomUUID().toString(), + projectId, + milestoneId, + title + ); + + var milestone = milestoneRepo.findById(milestoneId) + .orElseThrow(() -> new IllegalArgumentException("Milestone not found")); + + if (!milestone.projectId().equals(projectId)) { + throw new IllegalArgumentException("Milestone belongs to another project"); + } + + var newTicketIds = Stream.concat( + milestone.ticketIds().stream(), + Stream.of(ticket.id()) + ).distinct().toList(); + + var updatedMilestone = new Milestone( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.startDate(), + milestone.endDate(), + milestone.status(), + newTicketIds + ); + + try (var scope = StructuredTaskScope.open( + Joiner.allSuccessfulOrThrow(), + config -> config.withTimeout(Duration.ofSeconds(5)) + )) { + Subtask ticketSaveTask = scope.fork(() -> workItemRepo.save(ticket)); + scope.fork(() -> milestoneRepo.save(updatedMilestone)); + + scope.join(); + return (Ticket) ticketSaveTask.get(); + } catch (Exception e) { + // имаджинируем компенсирующие транзакции + throw new RuntimeException("Failed to create ticket transactionally: " + e.getMessage(), e); + } + } + + @Override + public Ticket assignTicket(User lead, String ticketId, User developer) { + Ticket ticket = findTicket(ticketId); + Project project = getProjectIfTeamLead(lead, ticket.projectId()); + + boolean canBeAssigned = project.getMember(developer) + .map(m -> Role.DEVELOPER == m.role() || m.isTeamLead()) + .orElse(false); + + if (!canBeAssigned) { + throw new IllegalArgumentException("User is not a Developer or TeamLead in this project"); + } + + List newAssignees = Stream.concat( + ticket.assignees().stream(), + Stream.of(developer) + ).distinct().toList(); + + var updatedTicket = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + TicketStatus.ACCEPTED, + newAssignees + ); + + return (Ticket) workItemRepo.save(updatedTicket); + } + + @Override + public Ticket verifyTicket(User lead, String ticketId, boolean approved) { + Ticket ticket = findTicket(ticketId); + getProjectIfTeamLead(lead, ticket.projectId()); + + if (approved && ticket.status() == TicketStatus.DONE) { + var compliance = qualityGateService.checkTicketReadyToClose(ticketId); + if (!compliance.passed()) { + throw new IllegalStateException("Quality Gates Failed: " + compliance.message()); + } + } + + TicketStatus nextStatus = switch (ticket.status()) { + case DONE -> approved ? TicketStatus.DONE : TicketStatus.IN_PROGRESS; + case NEW, ACCEPTED, IN_PROGRESS -> + throw new IllegalStateException("Ticket is not in DONE state, cannot verify."); + case CLOSED -> throw new IllegalStateException("Ticket is already CLOSED."); + }; + + var updatedTicket = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updatedTicket); + } + + @Override + public Ticket startProgress(User lead, String ticketId) { + Ticket ticket = findTicket(ticketId); + getProjectIfTeamLead(lead, ticket.projectId()); + + if (!ticket.assignees().contains(lead)) { + throw new IllegalStateException("You must assign yourself to this ticket before starting work."); + } + + TicketStatus nextStatus = switch (ticket.status()) { + case NEW, ACCEPTED -> TicketStatus.IN_PROGRESS; + case IN_PROGRESS -> throw new IllegalStateException("Ticket is already in progress."); + case DONE, CLOSED -> throw new IllegalStateException("Cannot work on finished ticket."); + }; + + var updated = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updated); + } + + @Override + public Ticket resolveTicket(User lead, String ticketId) { + Ticket ticket = findTicket(ticketId); + getProjectIfTeamLead(lead, ticket.projectId()); + + if (!ticket.assignees().contains(lead)) { + throw new IllegalStateException("You are not an assignee of this ticket."); + } + + TicketStatus nextStatus = switch (ticket.status()) { + case IN_PROGRESS -> TicketStatus.DONE; + case NEW, ACCEPTED -> throw new IllegalStateException("Ticket hasn't been started yet."); + case DONE -> throw new IllegalStateException("Ticket is already marked as DONE."); + case CLOSED -> throw new IllegalStateException("Ticket is closed."); + }; + + var updated = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updated); + } + + private Project getProjectIfTeamLead(User user, String projectId) { + Project project = projectRepo.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found: " + projectId)); + + var hasAccess = project.getTeamLead() + .orElseThrow(() -> new SecurityException("Project has no Team Lead")) + .id().equals(user.id()); + + if (!hasAccess) { + throw new SecurityException("User " + user.name() + " is not a Team Lead for project " + project.name()); + } + + return project; + } + + private Ticket findTicket(String ticketId) { + return workItemRepo.findById(ticketId) + .map(w -> switch (w) { + case Ticket t -> t; + case BugReport _ -> + throw new IllegalArgumentException("ID " + ticketId + " refers to a Bug, not a Ticket"); + }) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticketId)); + } +} diff --git a/src/main/java/org/lab/domain/model/enums/TicketStatus.java b/src/main/java/org/lab/domain/model/enums/TicketStatus.java index d800a40..737b12f 100644 --- a/src/main/java/org/lab/domain/model/enums/TicketStatus.java +++ b/src/main/java/org/lab/domain/model/enums/TicketStatus.java @@ -1,7 +1,7 @@ package org.lab.domain.model.enums; public enum TicketStatus { - OPEN, + NEW, ACCEPTED, IN_PROGRESS, DONE, diff --git a/src/main/java/org/lab/domain/model/workitems/Ticket.java b/src/main/java/org/lab/domain/model/workitems/Ticket.java index 394059f..dc4031a 100644 --- a/src/main/java/org/lab/domain/model/workitems/Ticket.java +++ b/src/main/java/org/lab/domain/model/workitems/Ticket.java @@ -15,6 +15,6 @@ public record Ticket( ) implements WorkItem { public static Ticket newTicket(String id, String projectId, String milestoneId, String title) { - return new Ticket(id, projectId, milestoneId, title, "", TicketStatus.OPEN, List.of()); + return new Ticket(id, projectId, milestoneId, title, "", TicketStatus.NEW, List.of()); } } From 40f260af2aa5c179240a47940c9ffd46ed4f51a8 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sat, 20 Dec 2025 23:59:48 +0300 Subject: [PATCH 11/15] feat: add developer service --- .../domain/application/DeveloperService.java | 16 ++ .../impl/DeveloperServiceImpl.java | 149 ++++++++++++++++++ .../application/impl/FindingUtilsService.java | 33 ++++ .../application/impl/ManagerServiceImpl.java | 21 +-- .../application/impl/TeamLeadServiceImpl.java | 27 ++-- .../lab/domain/model/user/ProjectMember.java | 8 + 6 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 src/main/java/org/lab/domain/application/DeveloperService.java create mode 100644 src/main/java/org/lab/domain/application/impl/DeveloperServiceImpl.java create mode 100644 src/main/java/org/lab/domain/application/impl/FindingUtilsService.java diff --git a/src/main/java/org/lab/domain/application/DeveloperService.java b/src/main/java/org/lab/domain/application/DeveloperService.java new file mode 100644 index 0000000..3dfa9d8 --- /dev/null +++ b/src/main/java/org/lab/domain/application/DeveloperService.java @@ -0,0 +1,16 @@ +package org.lab.domain.application; + +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Ticket; + +public interface DeveloperService { + // execution + Ticket startProgress(User lead, String ticketId); + + Ticket resolveTicket(User lead, String ticketId); + + // bugs + BugReport reportBug(User developer, String projectId, String title, String description); + BugReport fixBug(User developer, String bugId); +} diff --git a/src/main/java/org/lab/domain/application/impl/DeveloperServiceImpl.java b/src/main/java/org/lab/domain/application/impl/DeveloperServiceImpl.java new file mode 100644 index 0000000..be1d828 --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/DeveloperServiceImpl.java @@ -0,0 +1,149 @@ +package org.lab.domain.application.impl; + +import java.util.UUID; +import org.lab.domain.application.DeveloperService; +import org.lab.domain.model.enums.BugStatus; +import org.lab.domain.model.enums.TicketStatus; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class DeveloperServiceImpl implements DeveloperService { + + private final ProjectRepositoryPort projectRepo; + private final WorkItemRepositoryPort workItemRepo; + private final FindingUtilsService findingUtils; + + public DeveloperServiceImpl( + ProjectRepositoryPort projectRepo, + WorkItemRepositoryPort workItemRepo, + FindingUtilsService findingUtils + ) { + this.projectRepo = projectRepo; + this.workItemRepo = workItemRepo; + this.findingUtils = findingUtils; + } + + private Project getProjectIfDeveloper(User user, String projectId) { + Project project = projectRepo.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found")); + + boolean hasAccess = project.getMember(user) + .map(m -> m.isDeveloper() || m.isTeamLead()) + .orElse(false); + + if (!hasAccess) { + throw new SecurityException("User " + user.name() + " is not a Developer on this project"); + } + + return project; + } + + @Override + public Ticket startProgress(User developer, String ticketId) { + Ticket ticket = findingUtils.findTicket(ticketId); + getProjectIfDeveloper(developer, ticket.projectId()); + + if (!ticket.assignees().contains(developer)) { + throw new IllegalStateException("You are not assigned to this ticket."); + } + + // State Machine + TicketStatus nextStatus = switch (ticket.status()) { + case NEW, ACCEPTED -> TicketStatus.IN_PROGRESS; + case IN_PROGRESS -> throw new IllegalStateException("Ticket is already in progress."); + case DONE -> throw new IllegalStateException("Ticket is already DONE."); + case CLOSED -> throw new IllegalStateException("Ticket is CLOSED."); + }; + + var updated = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updated); + } + + @Override + public Ticket resolveTicket(User developer, String ticketId) { + Ticket ticket = findingUtils.findTicket(ticketId); + getProjectIfDeveloper(developer, ticket.projectId()); + + if (!ticket.assignees().contains(developer)) { + throw new IllegalStateException("You are not assigned to this ticket."); + } + + TicketStatus nextStatus = switch (ticket.status()) { + case IN_PROGRESS -> TicketStatus.DONE; + case NEW, ACCEPTED -> throw new IllegalStateException("Ticket must be started before finishing."); + case DONE, CLOSED -> throw new IllegalStateException("Ticket is already finished."); + }; + + var updated = new Ticket( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.description(), + nextStatus, + ticket.assignees() + ); + + return (Ticket) workItemRepo.save(updated); + } + + @Override + public BugReport reportBug(User developer, String projectId, String title, String description) { + getProjectIfDeveloper(developer, projectId); + + var bug = BugReport.createNew( + UUID.randomUUID().toString(), + projectId, + title, + developer + ); + + var fullBug = new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + description, + bug.status(), + bug.reporter() + ); + + return (BugReport) workItemRepo.save(fullBug); + } + + @Override + public BugReport fixBug(User developer, String bugId) { + BugReport bug = findingUtils.findBug(bugId); + getProjectIfDeveloper(developer, bug.projectId()); + + BugStatus nextStatus = switch (bug.status()) { + case NEW -> BugStatus.FIXED; + case FIXED -> throw new IllegalStateException("Bug is already marked as FIXED."); + case TESTED -> throw new IllegalStateException("Bug is already tested (wait for Close or Reopen)."); + case CLOSED -> throw new IllegalStateException("Bug is CLOSED."); + }; + + var fixedBug = new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + bug.description(), + nextStatus, + bug.reporter() + ); + + return (BugReport) workItemRepo.save(fixedBug); + } +} diff --git a/src/main/java/org/lab/domain/application/impl/FindingUtilsService.java b/src/main/java/org/lab/domain/application/impl/FindingUtilsService.java new file mode 100644 index 0000000..44691ce --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/FindingUtilsService.java @@ -0,0 +1,33 @@ +package org.lab.domain.application.impl; + +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Ticket; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class FindingUtilsService { + private final WorkItemRepositoryPort workItemRepo; + + public FindingUtilsService(WorkItemRepositoryPort workItemRepo) { + this.workItemRepo = workItemRepo; + } + + public Ticket findTicket(String ticketId) { + return workItemRepo.findById(ticketId) + .map(w -> switch (w) { + case Ticket t -> t; + case BugReport _ -> + throw new IllegalArgumentException("ID " + ticketId + " refers to a Bug, not a Ticket"); + }) + .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticketId)); + } + + public BugReport findBug(String ticketId) { + return workItemRepo.findById(ticketId) + .map(w -> switch (w) { + case BugReport b -> b; + case Ticket _ -> + throw new IllegalArgumentException("ID " + ticketId + " refers to a Ticket, not a Bug"); + }) + .orElseThrow(() -> new IllegalArgumentException("Bug not found: " + ticketId)); + } +} diff --git a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java index 16a5914..0c1221d 100644 --- a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java @@ -30,17 +30,20 @@ public class ManagerServiceImpl implements ManagerService { private final MilestoneRepositoryPort milestoneRepo; private final WorkItemRepositoryPort workItemRepo; private final QualityGateService qualityGateService; + private final FindingUtilsService findingUtilsService; public ManagerServiceImpl( ProjectRepositoryPort projectRepo, MilestoneRepositoryPort milestoneRepo, WorkItemRepositoryPort workItemRepo, - QualityGateService qualityGateService + QualityGateService qualityGateService, + FindingUtilsService findingUtilsService ) { this.projectRepo = projectRepo; this.milestoneRepo = milestoneRepo; this.workItemRepo = workItemRepo; this.qualityGateService = qualityGateService; + this.findingUtilsService = findingUtilsService; } @Override @@ -198,11 +201,11 @@ public Ticket createTicket(User manager, String projectId, String title, String @Override public Ticket assignTicket(User manager, String ticketId, User developer) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); Project project = getProjectIfManager(manager, ticket.projectId()); boolean isDev = project.getMember(developer) - .map(m -> Role.DEVELOPER == m.role()) + .map(ProjectMember::isDeveloper) .orElse(false); if (!isDev) { @@ -229,7 +232,7 @@ public Ticket assignTicket(User manager, String ticketId, User developer) { @Override public Ticket verifyTicket(User manager, String ticketId, boolean approved) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); getProjectIfManager(manager, ticket.projectId()); if (approved && ticket.status() == TicketStatus.DONE) { @@ -269,14 +272,4 @@ private Project getProjectIfManager(User user, String projectId) { return project; } - - private Ticket findTicket(String ticketId) { - return workItemRepo.findById(ticketId) - .map(w -> switch (w) { - case Ticket t -> t; - case BugReport _ -> - throw new IllegalArgumentException("ID " + ticketId + " refers to a Bug, not a Ticket"); - }) - .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticketId)); - } } diff --git a/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java b/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java index 12adbed..ed6018c 100644 --- a/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/TeamLeadServiceImpl.java @@ -9,10 +9,8 @@ import java.util.stream.Stream; import org.lab.domain.application.QualityGateService; import org.lab.domain.application.TeamLeadService; -import org.lab.domain.model.enums.Role; import org.lab.domain.model.enums.TicketStatus; import org.lab.domain.model.user.User; -import org.lab.domain.model.workitems.BugReport; import org.lab.domain.model.workitems.Milestone; import org.lab.domain.model.workitems.Project; import org.lab.domain.model.workitems.Ticket; @@ -27,17 +25,20 @@ public class TeamLeadServiceImpl implements TeamLeadService { private final MilestoneRepositoryPort milestoneRepo; private final WorkItemRepositoryPort workItemRepo; private final QualityGateService qualityGateService; + private final FindingUtilsService findingUtilsService; public TeamLeadServiceImpl( ProjectRepositoryPort projectRepo, MilestoneRepositoryPort milestoneRepo, WorkItemRepositoryPort workItemRepo, - QualityGateService qualityGateService + QualityGateService qualityGateService, + FindingUtilsService findingUtilsService ) { this.projectRepo = projectRepo; this.milestoneRepo = milestoneRepo; this.workItemRepo = workItemRepo; this.qualityGateService = qualityGateService; + this.findingUtilsService = findingUtilsService; } @Override @@ -91,11 +92,11 @@ public Ticket createTicket(User lead, String projectId, String title, String mil @Override public Ticket assignTicket(User lead, String ticketId, User developer) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); Project project = getProjectIfTeamLead(lead, ticket.projectId()); boolean canBeAssigned = project.getMember(developer) - .map(m -> Role.DEVELOPER == m.role() || m.isTeamLead()) + .map(m -> m.isDeveloper() || m.isTeamLead()) .orElse(false); if (!canBeAssigned) { @@ -122,7 +123,7 @@ public Ticket assignTicket(User lead, String ticketId, User developer) { @Override public Ticket verifyTicket(User lead, String ticketId, boolean approved) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); getProjectIfTeamLead(lead, ticket.projectId()); if (approved && ticket.status() == TicketStatus.DONE) { @@ -154,7 +155,7 @@ public Ticket verifyTicket(User lead, String ticketId, boolean approved) { @Override public Ticket startProgress(User lead, String ticketId) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); getProjectIfTeamLead(lead, ticket.projectId()); if (!ticket.assignees().contains(lead)) { @@ -182,7 +183,7 @@ public Ticket startProgress(User lead, String ticketId) { @Override public Ticket resolveTicket(User lead, String ticketId) { - Ticket ticket = findTicket(ticketId); + Ticket ticket = findingUtilsService.findTicket(ticketId); getProjectIfTeamLead(lead, ticket.projectId()); if (!ticket.assignees().contains(lead)) { @@ -223,14 +224,4 @@ private Project getProjectIfTeamLead(User user, String projectId) { return project; } - - private Ticket findTicket(String ticketId) { - return workItemRepo.findById(ticketId) - .map(w -> switch (w) { - case Ticket t -> t; - case BugReport _ -> - throw new IllegalArgumentException("ID " + ticketId + " refers to a Bug, not a Ticket"); - }) - .orElseThrow(() -> new IllegalArgumentException("Ticket not found: " + ticketId)); - } } diff --git a/src/main/java/org/lab/domain/model/user/ProjectMember.java b/src/main/java/org/lab/domain/model/user/ProjectMember.java index a6999bb..edc5433 100644 --- a/src/main/java/org/lab/domain/model/user/ProjectMember.java +++ b/src/main/java/org/lab/domain/model/user/ProjectMember.java @@ -11,4 +11,12 @@ public boolean isManager() { public boolean isTeamLead() { return role == Role.TEAM_LEAD; } + + public boolean isDeveloper() { + return role == Role.DEVELOPER; + } + + public boolean isTester() { + return role == Role.TESTER; + } } From c1e07c5b187ce13ed5e3e165ae09331884cd7e75 Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sun, 21 Dec 2025 00:04:22 +0300 Subject: [PATCH 12/15] feat: add tester service --- .../lab/domain/application/TesterService.java | 13 +++ .../application/impl/ManagerServiceImpl.java | 2 - .../application/impl/TesterServiceImpl.java | 100 ++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/lab/domain/application/TesterService.java create mode 100644 src/main/java/org/lab/domain/application/impl/TesterServiceImpl.java diff --git a/src/main/java/org/lab/domain/application/TesterService.java b/src/main/java/org/lab/domain/application/TesterService.java new file mode 100644 index 0000000..84fc92b --- /dev/null +++ b/src/main/java/org/lab/domain/application/TesterService.java @@ -0,0 +1,13 @@ +package org.lab.domain.application; + +import java.util.List; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; + +public interface TesterService { + BugReport createBug(User tester, String projectId, String title, String description); + + BugReport verifyBugFix(User tester, String bugId, boolean approved); + + List getBugsReadyForVerification(User tester, String projectId); +} diff --git a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java index 0c1221d..ad4d1a5 100644 --- a/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/ManagerServiceImpl.java @@ -11,11 +11,9 @@ import org.lab.domain.application.ManagerService; import org.lab.domain.application.QualityGateService; import org.lab.domain.model.enums.MilestoneStatus; -import org.lab.domain.model.enums.Role; import org.lab.domain.model.enums.TicketStatus; import org.lab.domain.model.user.ProjectMember; import org.lab.domain.model.user.User; -import org.lab.domain.model.workitems.BugReport; import org.lab.domain.model.workitems.Milestone; import org.lab.domain.model.workitems.Project; import org.lab.domain.model.workitems.Ticket; diff --git a/src/main/java/org/lab/domain/application/impl/TesterServiceImpl.java b/src/main/java/org/lab/domain/application/impl/TesterServiceImpl.java new file mode 100644 index 0000000..5d52e76 --- /dev/null +++ b/src/main/java/org/lab/domain/application/impl/TesterServiceImpl.java @@ -0,0 +1,100 @@ +package org.lab.domain.application.impl; + +import java.util.List; +import java.util.UUID; +import org.lab.domain.application.TesterService; +import org.lab.domain.model.enums.BugStatus; +import org.lab.domain.model.user.ProjectMember; +import org.lab.domain.model.user.User; +import org.lab.domain.model.workitems.BugReport; +import org.lab.domain.model.workitems.Project; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.domain.port.WorkItemRepositoryPort; + +public class TesterServiceImpl implements TesterService { + + private final ProjectRepositoryPort projectRepo; + private final WorkItemRepositoryPort workItemRepo; + private final FindingUtilsService findingUtils; + + public TesterServiceImpl( + ProjectRepositoryPort projectRepo, + WorkItemRepositoryPort workItemRepo, + FindingUtilsService findingUtils + ) { + this.projectRepo = projectRepo; + this.workItemRepo = workItemRepo; + this.findingUtils = findingUtils; + } + + private Project getProjectIfTester(User user, String projectId) { + Project project = projectRepo.findById(projectId) + .orElseThrow(() -> new IllegalArgumentException("Project not found")); + + boolean isTester = project.getMember(user) + .map(ProjectMember::isTester) + .orElse(false); + + if (!isTester) { + throw new SecurityException("User " + user.name() + " is not a Tester on project " + project.name()); + } + return project; + } + + @Override + public BugReport createBug(User tester, String projectId, String title, String description) { + getProjectIfTester(tester, projectId); + + var bug = BugReport.createNew( + UUID.randomUUID().toString(), + projectId, + title, + tester + ); + + var fullBug = new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + description, + bug.status(), + bug.reporter() + ); + + return (BugReport) workItemRepo.save(fullBug); + } + + @Override + public BugReport verifyBugFix(User tester, String bugId, boolean approved) { + BugReport bug = findingUtils.findBug(bugId); + getProjectIfTester(tester, bug.projectId()); + + BugStatus nextStatus = switch (bug.status()) { + case FIXED -> approved ? BugStatus.TESTED : BugStatus.NEW; + + case NEW -> throw new IllegalStateException("Bug is mostly NEW, developer hasn't fixed it yet."); + case TESTED -> throw new IllegalStateException("Bug is already verified."); + case CLOSED -> throw new IllegalStateException("Bug is already closed."); + }; + + var verifiedBug = new BugReport( + bug.id(), + bug.projectId(), + bug.title(), + bug.description(), + nextStatus, + bug.reporter() + ); + + return (BugReport) workItemRepo.save(verifiedBug); + } + + @Override + public List getBugsReadyForVerification(User tester, String projectId) { + getProjectIfTester(tester, projectId); + + return workItemRepo.findActiveBugsByProject(projectId).stream() + .filter(b -> b.status() == BugStatus.FIXED) + .toList(); + } +} From 48710a2e1d3f62522524b5afc1954cc1b7d7ff3d Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sun, 21 Dec 2025 16:42:26 +0300 Subject: [PATCH 13/15] fix: QualityGateService not working via missing scope.join() --- .../org/lab/domain/application/impl/QualityGateServiceImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java index e969dac..3deff2b 100644 --- a/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java +++ b/src/main/java/org/lab/domain/application/impl/QualityGateServiceImpl.java @@ -20,6 +20,8 @@ public ComplianceResult checkTicketReadyToClose(String ticketId) { Subtask docsTask = scope.fork(() -> checkDocumentation(ticketId)); Subtask coverageTask = scope.fork(() -> checkCodeCoverage(ticketId)); + scope.join(); + boolean allPassed = testsTask.get() && docsTask.get() && coverageTask.get(); if (allPassed) { From 463919f4132554b4bd3f3026c642e36a6233d68a Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sun, 21 Dec 2025 16:42:38 +0300 Subject: [PATCH 14/15] feat: implemented demo service --- src/main/java/org/lab/Main.java | 112 +++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index adaf245..8c6b9c3 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -1,3 +1,111 @@ -void main() { - IO.println("Hello and welcome!"); +package org.lab; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Gatherers; +import org.lab.domain.application.impl.DeveloperServiceImpl; +import org.lab.domain.application.impl.FindingUtilsService; +import org.lab.domain.application.impl.ManagerServiceImpl; +import org.lab.domain.application.impl.QualityGateServiceImpl; +import org.lab.domain.application.impl.TeamLeadServiceImpl; +import org.lab.domain.application.impl.TesterServiceImpl; +import org.lab.domain.application.impl.UserServiceImpl; +import org.lab.domain.model.enums.MilestoneStatus; +import org.lab.domain.model.user.User; +import org.lab.domain.port.ProjectRepositoryPort; +import org.lab.infrastructure.adapter.InMemoryMilestoneRepositoryAdapter; +import org.lab.infrastructure.adapter.InMemoryProjectRepositoryAdapter; +import org.lab.infrastructure.adapter.InMemoryUserRepositoryAdapter; +import org.lab.infrastructure.adapter.InMemoryWorkItemRepositoryAdapter; + +public class Main { + + static void main() { + var userRepo = new InMemoryUserRepositoryAdapter(); + var projectRepo = new InMemoryProjectRepositoryAdapter(); + var milestoneRepo = new InMemoryMilestoneRepositoryAdapter(); + var workItemRepo = new InMemoryWorkItemRepositoryAdapter(); + + var findingUtilsService = new FindingUtilsService(workItemRepo); + var qualityGateService = new QualityGateServiceImpl(); + + var userService = new UserServiceImpl(userRepo, projectRepo, workItemRepo); + var managerService = new ManagerServiceImpl(projectRepo, milestoneRepo, workItemRepo, qualityGateService, findingUtilsService); + var teamLeadService = new TeamLeadServiceImpl(projectRepo, milestoneRepo, workItemRepo, qualityGateService, findingUtilsService); + var developerService = new DeveloperServiceImpl(projectRepo, workItemRepo, findingUtilsService); + var testerService = new TesterServiceImpl(projectRepo, workItemRepo, findingUtilsService); + + var alice = userService.registerUser("Alice Manager"); + var bob = userService.registerUser("Bob Developer"); + var charlie = userService.registerUser("Charlie Tester"); + var dave = userService.registerUser("Dave TeamLead"); + + var project = userService.createProject(alice, "Super App 2024"); + + managerService.assignTeamLead(alice, project.id(), dave); + managerService.addDeveloper(alice, project.id(), bob); + managerService.addTester(alice, project.id(), charlie); + + printTeam(projectRepo, project.id()); + + var milestone = managerService.createMilestone(alice, project.id(), "MVP Release"); + managerService.updateMilestoneStatus(alice, milestone.id(), MilestoneStatus.ACTIVE); + System.out.printf("Milestone '%s' is now ACTIVE%n", milestone.name()); // printf is 'deliberately' not available in IO + IO.println("\n--- 3. Task Lifecycle (Ticket) ---"); + + IO.print("Creating ticket with Structured Concurrency... "); + var ticket = managerService.createTicket(alice, project.id(), "Implement Login OAuth", milestone.id()); + IO.println("Done. Ticket ID: " + ticket.id()); + + ticket = managerService.assignTicket(alice, ticket.id(), bob); + IO.println("Assigned to: " + ticket.assignees().getFirst().name()); + + ticket = developerService.startProgress(bob, ticket.id()); + IO.println("Bob starts working. Status: " + ticket.status()); + + ticket = developerService.resolveTicket(bob, ticket.id()); + IO.println("Bob finished working. Status: " + ticket.status()); + + ticket = managerService.verifyTicket(alice, ticket.id(), true); + IO.println("Alice verified the ticket. Final Status: " + ticket.status()); + + IO.println("\n--- 4. Quality Assurance (Bug) ---"); + + var bug = testerService.createBug(charlie, project.id(), "Login Page 404", "Clicking login throws 404"); + IO.println("Bug Reported by Charlie: " + bug.title()); + + bug = developerService.fixBug(bob, bug.id()); + IO.println("Bob fixed the bug. Status: " + bug.status()); + + bug = testerService.verifyBugFix(charlie, bug.id(), true); + IO.println("Charlie verified the fix. Status: " + bug.status()); + } + + private static void printTeam(ProjectRepositoryPort repo, String projectId) { + var project = repo.findById(projectId).orElseThrow(); + var members = project.getAllMembers(); + + var userRolesMap = members.stream().gather(Gatherers.>>fold( + HashMap::new, + (acc, user) -> { + var role = project.getRoleFor(user); + acc.computeIfAbsent(user.id(), _ -> new HashSet<>()).add(role.toString()); + return acc; + } + ) + ).findFirst().orElse(Map.of()); + + IO.println("Team created. Members:"); + userRolesMap.forEach((userId, roles) -> { + String userName = members.stream() + .filter(u -> u.id().equals(userId)) + .map(User::name) + .findFirst() + .orElse("Unknown"); + + IO.println(" - " + userName + " (" + userId + "): " + String.join(", ", roles)); + }); + } } From 5bd2eba6d20cf82b2ef6db4c928e930b1679a7dc Mon Sep 17 00:00:00 2001 From: "ar.r.lysenko" Date: Sun, 21 Dec 2025 17:14:36 +0300 Subject: [PATCH 15/15] doc: add feature list --- RESULT.md | 15 +++++++++++++++ src/main/java/org/lab/Main.java | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 RESULT.md diff --git a/RESULT.md b/RESULT.md new file mode 100644 index 0000000..85c3199 --- /dev/null +++ b/RESULT.md @@ -0,0 +1,15 @@ +# What was used + +Фичи, которые были использованы + +- java 16 [records](https://openjdk.org/jeps/395) +- java 21 [sequenced collections](https://openjdk.org/jeps/431) +- java 21 [virtual threads](https://openjdk.org/jeps/444) неявно в structured scope +- java 25 preview [structured concurrency](https://openjdk.org/jeps/505) +- java 17 [sealed classes](https://openjdk.org/jeps/409) +- java 21 [pattern matching for switch](https://openjdk.org/jeps/409) +- java 22 [unnamed variables](https://openjdk.org/jeps/456) +- java 24 [stream gatherers](https://openjdk.org/jeps/485) +- java 25 [Compact Source Files and Instance Main Methods](https://openjdk.org/jeps/512) +- java 23 [Markdown documentation comments](https://openjdk.org/jeps/467) +- etc diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index 8c6b9c3..580d875 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -22,7 +22,17 @@ public class Main { - static void main() { + /// Hello from **Markdown**! + /// + /// - I can use *list* + /// - I can use `code` + /// + /// ```java + /// IO.println("Hello from Java!"); + ///``` + /// + /// @throws IllegalStateException if the Universe has ended + void main() { var userRepo = new InMemoryUserRepositoryAdapter(); var projectRepo = new InMemoryProjectRepositoryAdapter(); var milestoneRepo = new InMemoryMilestoneRepositoryAdapter();