From 031d7c73161dfa3d86a4461f65f218d41ca13c06 Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Sun, 30 Nov 2025 17:22:06 +0000 Subject: [PATCH 1/2] 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 8d189d812a998fff299decae4b599df9a6b1ec53 Mon Sep 17 00:00:00 2001 From: poma12390 Date: Sun, 14 Dec 2025 21:15:44 +0300 Subject: [PATCH 2/2] lab done --- .gitignore | 1 + README.md | 588 ++++++++++++++--- TZ.md | 74 +++ build.gradle.kts | 49 +- settings.gradle.kts | 2 +- src/main/java/org/lab/Main.java | 12 +- src/main/java/org/lab/app/AccessControl.java | 55 ++ src/main/java/org/lab/app/AccessDenied.java | 31 + src/main/java/org/lab/app/ActorRole.java | 47 ++ src/main/java/org/lab/app/BugReportView.java | 15 + src/main/java/org/lab/app/DashboardView.java | 20 + src/main/java/org/lab/app/FailureCause.java | 36 ++ src/main/java/org/lab/app/MilestoneView.java | 16 + src/main/java/org/lab/app/Operation.java | 29 + src/main/java/org/lab/app/Presenter.java | 245 +++++++ .../org/lab/app/ProjectManagementService.java | 605 ++++++++++++++++++ src/main/java/org/lab/app/ProjectView.java | 16 + src/main/java/org/lab/app/Result.java | 89 +++ .../org/lab/app/TicketCompletionView.java | 10 + src/main/java/org/lab/app/TicketView.java | 19 + src/main/java/org/lab/app/Unit.java | 9 + src/main/java/org/lab/app/UserView.java | 9 + src/main/java/org/lab/cli/CliMain.java | 183 ++++++ src/main/java/org/lab/cli/CliRunner.java | 421 ++++++++++++ src/main/java/org/lab/cli/CliState.java | 108 ++++ src/main/java/org/lab/cli/Command.java | 141 ++++ src/main/java/org/lab/cli/CommandParser.java | 218 +++++++ src/main/java/org/lab/domain/BugReport.java | 151 +++++ .../java/org/lab/domain/BugReportAction.java | 10 + src/main/java/org/lab/domain/BugReportId.java | 19 + src/main/java/org/lab/domain/DateRange.java | 23 + src/main/java/org/lab/domain/Description.java | 18 + src/main/java/org/lab/domain/DomainError.java | 101 +++ .../java/org/lab/domain/DomainResult.java | 108 ++++ src/main/java/org/lab/domain/Milestone.java | 41 ++ src/main/java/org/lab/domain/MilestoneId.java | 19 + src/main/java/org/lab/domain/Project.java | 561 ++++++++++++++++ src/main/java/org/lab/domain/ProjectId.java | 19 + src/main/java/org/lab/domain/ProjectKey.java | 14 + src/main/java/org/lab/domain/Ticket.java | 140 ++++ .../java/org/lab/domain/TicketAction.java | 10 + src/main/java/org/lab/domain/TicketId.java | 19 + src/main/java/org/lab/domain/Title.java | 16 + src/main/java/org/lab/domain/User.java | 28 + src/main/java/org/lab/domain/UserId.java | 19 + src/main/java/org/lab/domain/Validation.java | 43 ++ .../java/org/lab/domain/enums/BugStatus.java | 8 + .../org/lab/domain/enums/MilestoneStatus.java | 7 + .../org/lab/domain/enums/ProjectRole.java | 8 + .../org/lab/domain/enums/TicketStatus.java | 8 + .../org/lab/infra/BugReportRepository.java | 128 ++++ .../java/org/lab/infra/ProjectRepository.java | 116 ++++ .../java/org/lab/infra/TicketRepository.java | 45 ++ .../java/org/lab/infra/UserRepository.java | 75 +++ 54 files changed, 4722 insertions(+), 80 deletions(-) create mode 100644 TZ.md create mode 100644 src/main/java/org/lab/app/AccessControl.java create mode 100644 src/main/java/org/lab/app/AccessDenied.java create mode 100644 src/main/java/org/lab/app/ActorRole.java create mode 100644 src/main/java/org/lab/app/BugReportView.java create mode 100644 src/main/java/org/lab/app/DashboardView.java create mode 100644 src/main/java/org/lab/app/FailureCause.java create mode 100644 src/main/java/org/lab/app/MilestoneView.java create mode 100644 src/main/java/org/lab/app/Operation.java create mode 100644 src/main/java/org/lab/app/Presenter.java create mode 100644 src/main/java/org/lab/app/ProjectManagementService.java create mode 100644 src/main/java/org/lab/app/ProjectView.java create mode 100644 src/main/java/org/lab/app/Result.java create mode 100644 src/main/java/org/lab/app/TicketCompletionView.java create mode 100644 src/main/java/org/lab/app/TicketView.java create mode 100644 src/main/java/org/lab/app/Unit.java create mode 100644 src/main/java/org/lab/app/UserView.java create mode 100644 src/main/java/org/lab/cli/CliMain.java create mode 100644 src/main/java/org/lab/cli/CliRunner.java create mode 100644 src/main/java/org/lab/cli/CliState.java create mode 100644 src/main/java/org/lab/cli/Command.java create mode 100644 src/main/java/org/lab/cli/CommandParser.java create mode 100644 src/main/java/org/lab/domain/BugReport.java create mode 100644 src/main/java/org/lab/domain/BugReportAction.java create mode 100644 src/main/java/org/lab/domain/BugReportId.java create mode 100644 src/main/java/org/lab/domain/DateRange.java create mode 100644 src/main/java/org/lab/domain/Description.java create mode 100644 src/main/java/org/lab/domain/DomainError.java create mode 100644 src/main/java/org/lab/domain/DomainResult.java create mode 100644 src/main/java/org/lab/domain/Milestone.java create mode 100644 src/main/java/org/lab/domain/MilestoneId.java create mode 100644 src/main/java/org/lab/domain/Project.java create mode 100644 src/main/java/org/lab/domain/ProjectId.java create mode 100644 src/main/java/org/lab/domain/ProjectKey.java create mode 100644 src/main/java/org/lab/domain/Ticket.java create mode 100644 src/main/java/org/lab/domain/TicketAction.java create mode 100644 src/main/java/org/lab/domain/TicketId.java create mode 100644 src/main/java/org/lab/domain/Title.java create mode 100644 src/main/java/org/lab/domain/User.java create mode 100644 src/main/java/org/lab/domain/UserId.java create mode 100644 src/main/java/org/lab/domain/Validation.java create mode 100644 src/main/java/org/lab/domain/enums/BugStatus.java create mode 100644 src/main/java/org/lab/domain/enums/MilestoneStatus.java create mode 100644 src/main/java/org/lab/domain/enums/ProjectRole.java create mode 100644 src/main/java/org/lab/domain/enums/TicketStatus.java create mode 100644 src/main/java/org/lab/infra/BugReportRepository.java create mode 100644 src/main/java/org/lab/infra/ProjectRepository.java create mode 100644 src/main/java/org/lab/infra/TicketRepository.java create mode 100644 src/main/java/org/lab/infra/UserRepository.java diff --git a/.gitignore b/.gitignore index b63da45..df54656 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ !**/src/test/**/build/ ### IntelliJ IDEA ### +.idea/ .idea/modules.xml .idea/jarRepositories.xml .idea/compiler.xml diff --git a/README.md b/README.md index 19aec56..6ce43ac 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,514 @@ -[![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 -* sealed классы и record -* программирование в функциональном стиле -* preview как project Valhalla, structured concurrency... -* и т.д. - -# Обязательное условие: -* Использование системы сборки Gradle -* Код должен быть отлажен и протестирован - -# Дедлайн 24.12.2025 23:59 - -# Задание -Бизнес-логика для системы управления проектами. Система позволяет группе разработчиков управлять разработкой программных проектов. В ней определены следующие объекты: -* Проект. У каждого проекта есть определенная команда разработчиков, тестировщиков и один менеджер. Также к проекту может быть привязан тимлидер. У проекта определены различные майлстоуны. К каждому проекту могут быть привязаны сообщения об ошибках. -* Майлстоун. Одна из итераций цикла разработки проекта. Привязан к определенным датам. К майлстоунам привязаны определенные тикеты (задания). Майлстоун имеет определенный статус: открыт, активен или закрыт. Майлстоун может быть закрыт только когда все его тикеты выполнены. В каждый момент времени у проекта может быть только один майлстоун. -* Тикет. Определенное задание для разработчиков. Может быть выдано определенной группе разработчиков. Привязан к определенному проекту и майлстоуну. Имеет статус: новый, принятый, в процессе выполнения, выполнен. -* Сообщение об ошибке. Отчет о найденной ошибке в проекте. Привязан к определенному проекту. Имеет статус: новый, исправленный, протестированный, закрытый. - -В системе определены следующие роли для пользователей: -* менеджер; -* тимлидер; -* разработчик; -* тестировщик. - Для каждого проекта у пользователя определена своя роль (если он участвует в разработке данного проекта). - -У всех пользователей системы есть возможность: -* зарегистрироваться; -* просмотреть все проекты в которых они участвуют; -* посмотреть список заданий, который был им выдан; -* посмотреть список отчетов об ошибках, которые ему надо исправить; -* создать новый проект. - -Функции менеджера проекта: -* Управление пользователями: -1. назначение тимлидера -2. добавление разработчика к проекту -3. добавление тестировщика к проекту - -* Управление майлстоунами: -1. создание нового майлстоуна -2. изменение статуса майлстоуна - -* Управление тикетами -1. создание нового тикета -2. привязка разработчика к тикету -3. проверка выполнения тикета - -Функции тимлидера: -* Управление тикетами -1. создание нового тикета -2. привязка разработчика к тикету -3. проверка выполнения тикета - -* Выполнение тикетов - -Функции разработчика: -* Выполнение тикетов -* Создание сообщений об ошибках -* Исправление сообщений об ошибках - -Функции тестировщика: -* Тестирование проекта -* Создание сообщений об ошибках -* Проверка исправления сообщений об ошибках \ No newline at end of file +# Workflows и команды для тестирования (Java 26 + Gradle + preview) + +Этот файл предназначен для демонстрации и проверки лабораторной работы: запуск тестов, прогон сценариев CLI, проверка прав/инвариантов и диагностика типовых проблем (кодировка, preview). + +--- + +## 1) Быстрый старт: один прогон “всё работает” + +### PowerShell +```powershell +./gradlew clean test +./gradlew -q run +``` + +### CMD (рекомендуется для корректной кодировки в консоли) +```bat +chcp 65001 +.\gradlew clean test +.\gradlew -q run +``` + +--- + +## 2) Тесты JUnit (Gradle) + +### Запуск всех тестов +```powershell +./gradlew test +``` + +### Чистый прогон (полезно, если что-то “кешируется”) +```powershell +./gradlew clean test +``` + +### Больше деталей (помогает при падениях) +```powershell +./gradlew test --info +./gradlew test --stacktrace +``` + +### Запуск конкретного тест-класса (JUnit Platform) +(Работает, если у тебя стандартная структура `src/test/java` и JUnit 5.) +```powershell +./gradlew test --tests "org.lab.*" +``` + +--- + +## 3) Запуск приложения (CLI) через Gradle + +### Запуск CLI +```powershell +./gradlew run +``` + +### Запуск без “шума” Gradle (удобно на демо) +```powershell +./gradlew -q run +``` + +### Диагностика падений приложения +```powershell +./gradlew run --stacktrace +./gradlew run --info +``` + +--- + + +--- + +## Команды CLI: полный список, описание и примеры + +Ниже перечислены **все команды**, которые поддерживает CLI, с кратким назначением, синтаксисом и примером. + +### Общие команды + +#### `help` +Показывает справку по командам и форматам ссылок (`lastProject`, `lastTicket`, и т.д.). + +**Пример** +```text +help +``` + +#### `demo` +Запускает встроенный демонстрационный сценарий (последовательность команд). + +**Пример** +```text +demo +``` + +#### `exit` +Завершает работу CLI. + +**Пример** +```text +exit +``` + +--- + +### Пользователи + +#### `register "Display Name"` +Регистрирует пользователя в системе и привязывает `login` к созданному `userId`. + +**Пример** +```text +register manager "Project Manager" +register dev "Backend Developer" +register tester "QA Tester" +``` + +--- + +### Проекты + +#### `create-project "Project Name" "Description"` +Создаёт новый проект от имени пользователя `actorLogin`. Создатель становится менеджером проекта. + +**Ссылки на проект (`projectRef`)** +- ключ проекта (например, `PRJ-000001`) +- UUID проекта +- `lastProject` или `last` + +**Пример** +```text +create-project manager "Demo Project" "Project created from CLI" +``` + +#### `dashboard ` +Показывает Dashboard пользователя: проекты, тикеты, баги “к исправлению/проверке”. +Внутри сервиса Dashboard собирается параллельно (structured concurrency, preview). + +**Пример** +```text +dashboard manager +dashboard dev +dashboard tester +``` + +--- + +### Участники проекта и роли (обычно менеджер) + +#### `add-dev ` +Добавляет пользователя `memberLogin` в проект как разработчика. + +**Пример** +```text +add-dev manager lastProject dev +``` + +#### `add-tester ` +Добавляет пользователя `memberLogin` в проект как тестировщика. + +**Пример** +```text +add-tester manager lastProject tester +``` + +--- + +### Milestone’ы + +#### `create-milestone "Milestone Name" ` +Создаёт milestone в проекте с указанным диапазоном дат. + +**Пример** +```text +create-milestone manager lastProject "Milestone 1" 2025-12-14 2025-12-21 +``` + +#### `activate-milestone ` +Переводит milestone в статус ACTIVE. + +**Ссылки на milestone (`milestoneRef`)** +- UUID milestone +- `lastMilestone` или `last` + +**Пример** +```text +activate-milestone manager lastProject lastMilestone +``` + +--- + +### Ticket’ы + +#### `create-ticket "Title" "Description"` +Создаёт тикет в milestone проекта. + +**Пример** +```text +create-ticket manager lastProject lastMilestone "Implement feature A" "Implement business logic A" +``` + +#### `assign-ticket ` +Назначает разработчика на тикет. + +**Ссылки на ticket (`ticketRef`)** +- UUID тикета +- `lastTicket` или `last` + +**Пример** +```text +assign-ticket manager lastProject lastTicket dev +``` + +#### `start-ticket ` +Начинает выполнение тикета. В зависимости от текущего статуса может сначала принять тикет (если у тебя реализовано “доведение” статусов в CLI). + +**Пример** +```text +start-ticket dev lastProject lastTicket +``` + +#### `done-ticket ` +Завершает тикет (DONE). Аналогично может “доводить” тикет через промежуточные статусы, если так реализовано в CLI. + +**Пример** +```text +done-ticket dev lastProject lastTicket +``` + +--- + +### Bug Report’ы + +#### `create-bug "Title" "Description"` +Создаёт bug report в проекте. + +**Пример** +```text +create-bug tester lastProject "Bug A" "Found defect during testing" +``` + +#### `fix-bug ` +Помечает bug report как FIXED (обычно делает разработчик). + +**Ссылки на bug (`bugRef`)** +- UUID bug report +- `lastBug` или `last` + +**Пример** +```text +fix-bug dev lastProject lastBug +``` + +#### `test-bug ` +Помечает bug report как TESTED (обычно делает тестировщик). + +**Пример** +```text +test-bug tester lastProject lastBug +``` + +#### `close-bug ` +Закрывает bug report (CLOSED), обычно после TESTED. + +**Пример** +```text +close-bug tester lastProject lastBug +``` + +--- + +### Примечания по ссылкам (refs): `lastProject`, `lastTicket`, … + +CLI поддерживает короткие ссылки на “последние” созданные сущности в рамках текущего процесса. + +- `lastProject` / `last` — последний созданный проект +- `lastMilestone` / `last` — последний milestone в рамках `lastProject` (или текущего `projectRef`) +- `lastTicket` / `last` — последний тикет в рамках проекта +- `lastBug` / `last` — последний bug report в рамках проекта + +Для точности на демонстрации можно всегда использовать **UUID/KEY** из вывода CLI вместо `last*`. + + +## 4) Workflow: Happy-path end-to-end (manager → dev → tester → dashboard) + +Вставляй команды по одной в CLI после запуска `./gradlew run`. + +```text +register manager "Project Manager" +register dev "Backend Developer" +register tester "QA Tester" + +create-project manager "Demo Project" "Project created from workflow" +dashboard manager + +add-dev manager lastProject dev +add-tester manager lastProject tester + +create-milestone manager lastProject "Milestone 1" 2025-12-14 2025-12-21 +activate-milestone manager lastProject lastMilestone + +create-ticket manager lastProject lastMilestone "Implement feature A" "Implement business logic A" +assign-ticket manager lastProject lastTicket dev + +dashboard dev + +start-ticket dev lastProject lastTicket +done-ticket dev lastProject lastTicket + +create-bug tester lastProject "Bug A" "Found defect during testing" +fix-bug dev lastProject lastBug +test-bug tester lastProject lastBug +close-bug tester lastProject lastBug + +dashboard manager +``` + +Что проверяет: +- регистрацию +- создание проекта +- назначение участников +- milestone lifecycle (создание/активация) +- ticket lifecycle (назначение/выполнение) +- bug lifecycle (создание/исправление/проверка/закрытие) +- dashboard (сборка данных параллельно внутри сервиса) + +--- + +## 5) Workflow: проверка прав (ожидаемые ACCESS_DENIED) + +```text +register manager "Project Manager" +register dev "Backend Developer" +register tester "QA Tester" + +create-project manager "Security Demo" "Access control checks" +add-dev manager lastProject dev +add-tester manager lastProject tester + +create-milestone manager lastProject "Iter" 2025-12-14 2025-12-20 +activate-milestone manager lastProject lastMilestone +create-ticket manager lastProject lastMilestone "Sec ticket" "Ticket for access checks" +assign-ticket manager lastProject lastTicket dev +``` + +Негативные попытки (часть должна отказать по ролям): +```text +add-dev dev lastProject tester +create-milestone dev lastProject "Should fail" 2025-12-14 2025-12-15 +assign-ticket tester lastProject lastTicket tester +``` + +Ожидаемо: Failure с типом `ACCESS_DENIED` (или иной конкретный FailureCause, если у тебя правила иные). + +--- + +## 6) Workflow: статус-машина тикета (NEW → ACCEPTED → IN_PROGRESS → DONE) + +```text +register manager "Project Manager" +register dev "Backend Developer" + +create-project manager "Ticket State Machine" "Show transitions" +add-dev manager lastProject dev + +create-milestone manager lastProject "Iter" 2025-12-14 2025-12-16 +activate-milestone manager lastProject lastMilestone + +create-ticket manager lastProject lastMilestone "State demo ticket" "Observe transitions" +assign-ticket manager lastProject lastTicket dev + +dashboard dev +start-ticket dev lastProject lastTicket +dashboard dev +done-ticket dev lastProject lastTicket +dashboard dev +``` + +--- + +## 7) Workflow: статус-машина баг-репорта (NEW → FIXED → TESTED → CLOSED) + +```text +register manager "Project Manager" +register dev "Backend Developer" +register tester "QA Tester" + +create-project manager "Bug Lifecycle Project" "Show bug transitions" +add-dev manager lastProject dev +add-tester manager lastProject tester + +create-bug tester lastProject "Bug lifecycle demo" "Bug created for transition demo" +dashboard tester + +fix-bug dev lastProject lastBug +dashboard dev + +test-bug tester lastProject lastBug +close-bug tester lastProject lastBug + +dashboard manager +``` + +--- + +## 8) Workflow: доменные ошибки/валидации (NotFound, invalid range) + +### NotFound (вставь заведомо несуществующие UUID) +```text +register manager "Project Manager" + +activate-milestone manager 00000000-0000-0000-0000-000000000000 00000000-0000-0000-0000-000000000000 +done-ticket manager 00000000-0000-0000-0000-000000000000 00000000-0000-0000-0000-000000000000 +close-bug manager 00000000-0000-0000-0000-000000000000 00000000-0000-0000-0000-000000000000 +``` + +### Неверный диапазон дат milestone (если домен валидирует start <= end) +```text +register manager "Project Manager" +create-project manager "Invalid milestone dates" "Range check" +create-milestone manager lastProject "Bad range" 2025-12-20 2025-12-14 +``` + +--- + +## 9) Dashboard как демонстрация structured concurrency (preview) + +Сценарий, насыщенный данными, чтобы показать параллельную сборку: +```text +register manager "Project Manager" +register dev "Backend Developer" +register tester "QA Tester" + +create-project manager "Concurrency Dashboard" "Fill data and query dashboards" +add-dev manager lastProject dev +add-tester manager lastProject tester + +create-milestone manager lastProject "Iter" 2025-12-14 2025-12-31 +activate-milestone manager lastProject lastMilestone + +create-ticket manager lastProject lastMilestone "Ticket #1" "Work item 1" +assign-ticket manager lastProject lastTicket dev +start-ticket dev lastProject lastTicket +done-ticket dev lastProject lastTicket + +create-bug tester lastProject "Bug #1" "First bug" +fix-bug dev lastProject lastBug + +dashboard manager +dashboard dev +dashboard tester +``` + +--- + +## 10) Прогон сценария из файла (без ручного ввода) + +### Windows CMD +1) Создай файл `scenario.txt` в корне проекта, по одной команде на строку. +2) Запусти: +```bat +type scenario.txt | .\gradlew -q run +``` + +### PowerShell +```powershell +Get-Content .\scenario.txt | ./gradlew -q run +``` + +--- + +## 11) Типовые проблемы и быстрые решения + +### 11.1 “preview feature … disabled by default” +Проверь, что в Gradle для **compileJava/test/run** добавлен `--enable-preview`. + +Проверка: +```powershell +./gradlew -q tasks +``` + +И запуск с подробностями: +```powershell +./gradlew run --info +./gradlew test --info +``` + +### 11.2 “DEMO ��������” (битая кодировка) +CMD: +```bat +chcp 65001 +.\gradlew -q run +``` + +И/или JVM-аргумент `-Dfile.encoding=UTF-8` должен быть добавлен в JavaExec/Test. + +### 11.3 В CLI “после команд ничего не печатается” +Это означает, что CLI не печатает результат `Result` после выполнения команды. +Исправление: в CLI после `runner.execute(cmd)` обязательно делать `System.out.println(...)` для Success/Failure. + +--- + +## 12) Мини-чеклист перед демонстрацией преподавателю + +1) `./gradlew clean test` — зелёный. +2) `./gradlew -q run` — CLI запускается без падений. +3) Вставить workflow из раздела 4 — должны быть видны: + - создание проекта + - создание milestone + - создание ticket + - создание bug + - dashboard +4) Запустить workflow из раздела 5 — показать отказ по ролям. +5) Показать `dashboard` как пример structured concurrency. + diff --git a/TZ.md b/TZ.md new file mode 100644 index 0000000..19aec56 --- /dev/null +++ b/TZ.md @@ -0,0 +1,74 @@ +[![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 +* sealed классы и record +* программирование в функциональном стиле +* preview как project Valhalla, structured concurrency... +* и т.д. + +# Обязательное условие: +* Использование системы сборки Gradle +* Код должен быть отлажен и протестирован + +# Дедлайн 24.12.2025 23:59 + +# Задание +Бизнес-логика для системы управления проектами. Система позволяет группе разработчиков управлять разработкой программных проектов. В ней определены следующие объекты: +* Проект. У каждого проекта есть определенная команда разработчиков, тестировщиков и один менеджер. Также к проекту может быть привязан тимлидер. У проекта определены различные майлстоуны. К каждому проекту могут быть привязаны сообщения об ошибках. +* Майлстоун. Одна из итераций цикла разработки проекта. Привязан к определенным датам. К майлстоунам привязаны определенные тикеты (задания). Майлстоун имеет определенный статус: открыт, активен или закрыт. Майлстоун может быть закрыт только когда все его тикеты выполнены. В каждый момент времени у проекта может быть только один майлстоун. +* Тикет. Определенное задание для разработчиков. Может быть выдано определенной группе разработчиков. Привязан к определенному проекту и майлстоуну. Имеет статус: новый, принятый, в процессе выполнения, выполнен. +* Сообщение об ошибке. Отчет о найденной ошибке в проекте. Привязан к определенному проекту. Имеет статус: новый, исправленный, протестированный, закрытый. + +В системе определены следующие роли для пользователей: +* менеджер; +* тимлидер; +* разработчик; +* тестировщик. + Для каждого проекта у пользователя определена своя роль (если он участвует в разработке данного проекта). + +У всех пользователей системы есть возможность: +* зарегистрироваться; +* просмотреть все проекты в которых они участвуют; +* посмотреть список заданий, который был им выдан; +* посмотреть список отчетов об ошибках, которые ему надо исправить; +* создать новый проект. + +Функции менеджера проекта: +* Управление пользователями: +1. назначение тимлидера +2. добавление разработчика к проекту +3. добавление тестировщика к проекту + +* Управление майлстоунами: +1. создание нового майлстоуна +2. изменение статуса майлстоуна + +* Управление тикетами +1. создание нового тикета +2. привязка разработчика к тикету +3. проверка выполнения тикета + +Функции тимлидера: +* Управление тикетами +1. создание нового тикета +2. привязка разработчика к тикету +3. проверка выполнения тикета + +* Выполнение тикетов + +Функции разработчика: +* Выполнение тикетов +* Создание сообщений об ошибках +* Исправление сообщений об ошибках + +Функции тестировщика: +* Тестирование проекта +* Создание сообщений об ошибках +* Проверка исправления сообщений об ошибках \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..79dfd53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,10 @@ +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.api.tasks.testing.Test + plugins { id("java") + id("application") } group = "org.lab" @@ -9,12 +14,52 @@ repositories { mavenCentral() } +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(26)) + } +} + dependencies { testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } -tasks.test { +application { + mainClass.set("org.lab.Main") +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + // Чтобы компилировать preview-фичи (например pattern matching/switch preview, string templates и т.д.) + options.compilerArgs.add("--enable-preview") + // Фиксируем релиз, чтобы компиляция была строго под Java 26 + options.release.set(26) +} + +tasks.withType().configureEach { useJUnitPlatform() -} \ No newline at end of file + // Чтобы тесты могли запускать код с preview-фичами + jvmArgs("--enable-preview") +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview", "-Dfile.encoding=UTF-8") + standardInput = System.`in` +} + +tasks.withType().configureEach { + useJUnitPlatform() + jvmArgs("--enable-preview", "-Dfile.encoding=UTF-8") +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.compilerArgs.add("--enable-preview") +} + +tasks.withType().configureEach { + // Чтобы `gradlew run` запускал приложение с preview-фичами + jvmArgs("--enable-preview") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 10d1b39..6dd98eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "features" \ No newline at end of file +rootProject.name = "modern-features-poma12390" diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java index 22028ef..79e3e4a 100644 --- a/src/main/java/org/lab/Main.java +++ b/src/main/java/org/lab/Main.java @@ -1,4 +1,10 @@ -void main() { - IO.println("Hello and welcome!"); -} +package org.lab; + +import org.lab.cli.CliMain; +public final class Main { + + public static void main(String[] args) throws Exception { + CliMain.main(args); + } +} diff --git a/src/main/java/org/lab/app/AccessControl.java b/src/main/java/org/lab/app/AccessControl.java new file mode 100644 index 0000000..561ae9a --- /dev/null +++ b/src/main/java/org/lab/app/AccessControl.java @@ -0,0 +1,55 @@ +package org.lab.app; + +import java.util.EnumSet; +import java.util.Set; + +public final class AccessControl { + + private static final Set MANAGER = EnumSet.of( + Operation.ASSIGN_TEAM_LEAD, + Operation.ADD_DEVELOPER, + Operation.ADD_TESTER, + Operation.CREATE_MILESTONE, + Operation.ACTIVATE_MILESTONE, + Operation.CLOSE_MILESTONE, + Operation.CREATE_TICKET, + Operation.ASSIGN_TICKET_DEVELOPER, + Operation.CHECK_TICKET_COMPLETION, + Operation.CLOSE_BUG_REPORT + ); + + private static final Set TEAM_LEAD = EnumSet.of( + Operation.CREATE_TICKET, + Operation.ASSIGN_TICKET_DEVELOPER, + Operation.CHECK_TICKET_COMPLETION, + Operation.TICKET_ACCEPT, + Operation.TICKET_START, + Operation.TICKET_COMPLETE + ); + + private static final Set DEVELOPER = EnumSet.of( + Operation.TICKET_ACCEPT, + Operation.TICKET_START, + Operation.TICKET_COMPLETE, + Operation.CREATE_BUG_REPORT, + Operation.FIX_BUG_REPORT + ); + + private static final Set TESTER = EnumSet.of( + Operation.CREATE_BUG_REPORT, + Operation.TEST_BUG_REPORT, + Operation.CLOSE_BUG_REPORT + ); + + private AccessControl() { } + + public static boolean isAllowed(ActorRole role, Operation operation) { + return switch (role) { + case ActorRole.Manager ignored -> MANAGER.contains(operation); + case ActorRole.TeamLead ignored -> TEAM_LEAD.contains(operation); + case ActorRole.Developer ignored -> DEVELOPER.contains(operation); + case ActorRole.Tester ignored -> TESTER.contains(operation); + case ActorRole.Outsider ignored -> false; + }; + } +} diff --git a/src/main/java/org/lab/app/AccessDenied.java b/src/main/java/org/lab/app/AccessDenied.java new file mode 100644 index 0000000..cca63ae --- /dev/null +++ b/src/main/java/org/lab/app/AccessDenied.java @@ -0,0 +1,31 @@ +package org.lab.app; + +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; + +import java.util.Objects; + +public record AccessDenied( + UserId actorId, + ProjectId projectId, + String operation, + String role +) implements FailureCause { + + public AccessDenied { + Objects.requireNonNull(actorId, "actorId"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(role, "role"); + } + + @Override + public String code() { + return "ACCESS_DENIED"; + } + + @Override + public String message() { + return "Access denied: role=" + role + ", op=" + operation + ", actor=" + actorId + ", project=" + projectId; + } +} diff --git a/src/main/java/org/lab/app/ActorRole.java b/src/main/java/org/lab/app/ActorRole.java new file mode 100644 index 0000000..b0d94e2 --- /dev/null +++ b/src/main/java/org/lab/app/ActorRole.java @@ -0,0 +1,47 @@ +package org.lab.app; + + +import org.lab.domain.enums.ProjectRole; + +import java.util.Objects; +import java.util.Optional; + +public sealed interface ActorRole permits ActorRole.Manager, ActorRole.TeamLead, ActorRole.Developer, ActorRole.Tester, ActorRole.Outsider { + + String name(); + + static ActorRole from(Optional roleOpt) { + Objects.requireNonNull(roleOpt, "roleOpt"); + return roleOpt.map(ActorRole::from).orElseGet(Outsider::new); + } + + static ActorRole from(ProjectRole role) { + Objects.requireNonNull(role, "role"); + return switch (role) { + case MANAGER -> new Manager(); + case TEAM_LEAD -> new TeamLead(); + case DEVELOPER -> new Developer(); + case TESTER -> new Tester(); + }; + } + + record Manager() implements ActorRole { + @Override public String name() { return "MANAGER"; } + } + + record TeamLead() implements ActorRole { + @Override public String name() { return "TEAM_LEAD"; } + } + + record Developer() implements ActorRole { + @Override public String name() { return "DEVELOPER"; } + } + + record Tester() implements ActorRole { + @Override public String name() { return "TESTER"; } + } + + record Outsider() implements ActorRole { + @Override public String name() { return "OUTSIDER"; } + } +} diff --git a/src/main/java/org/lab/app/BugReportView.java b/src/main/java/org/lab/app/BugReportView.java new file mode 100644 index 0000000..4f637f2 --- /dev/null +++ b/src/main/java/org/lab/app/BugReportView.java @@ -0,0 +1,15 @@ +package org.lab.app; + + +import org.lab.domain.BugReportId; +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; +import org.lab.domain.enums.BugStatus; + +public record BugReportView( + BugReportId id, + ProjectId projectId, + String title, + BugStatus status, + UserId assignedTo +) { } diff --git a/src/main/java/org/lab/app/DashboardView.java b/src/main/java/org/lab/app/DashboardView.java new file mode 100644 index 0000000..e319067 --- /dev/null +++ b/src/main/java/org/lab/app/DashboardView.java @@ -0,0 +1,20 @@ +package org.lab.app; + +import org.lab.domain.UserId; + +import java.util.List; +import java.util.Objects; + +public record DashboardView( + UserId userId, + List projects, + List tickets, + List actionableBugs +) { + public DashboardView { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(projects, "projects"); + Objects.requireNonNull(tickets, "tickets"); + Objects.requireNonNull(actionableBugs, "actionableBugs"); + } +} diff --git a/src/main/java/org/lab/app/FailureCause.java b/src/main/java/org/lab/app/FailureCause.java new file mode 100644 index 0000000..686b2ab --- /dev/null +++ b/src/main/java/org/lab/app/FailureCause.java @@ -0,0 +1,36 @@ +package org.lab.app; + +import org.lab.domain.DomainError; + +import java.util.Objects; +import java.util.Optional; + +public sealed interface FailureCause permits FailureCause.Domain, AccessDenied { + + String code(); + + String message(); + + default Optional asDomainError() { + return switch (this) { + case Domain(var err) -> Optional.of(err); + case AccessDenied ignored -> Optional.empty(); + }; + } + + record Domain(DomainError error) implements FailureCause { + public Domain { + Objects.requireNonNull(error, "error"); + } + + @Override + public String code() { + return error.code(); + } + + @Override + public String message() { + return error.userMessage(); + } + } +} diff --git a/src/main/java/org/lab/app/MilestoneView.java b/src/main/java/org/lab/app/MilestoneView.java new file mode 100644 index 0000000..9b85931 --- /dev/null +++ b/src/main/java/org/lab/app/MilestoneView.java @@ -0,0 +1,16 @@ +package org.lab.app; + +import org.lab.domain.MilestoneId; +import org.lab.domain.ProjectId; +import org.lab.domain.enums.MilestoneStatus; + +import java.time.LocalDate; + +public record MilestoneView( + MilestoneId id, + ProjectId projectId, + String name, + LocalDate start, + LocalDate end, + MilestoneStatus status +) { } diff --git a/src/main/java/org/lab/app/Operation.java b/src/main/java/org/lab/app/Operation.java new file mode 100644 index 0000000..6da74d6 --- /dev/null +++ b/src/main/java/org/lab/app/Operation.java @@ -0,0 +1,29 @@ +package org.lab.app; + +public enum Operation { + // Project users management + ASSIGN_TEAM_LEAD, + ADD_DEVELOPER, + ADD_TESTER, + + // Milestones + CREATE_MILESTONE, + ACTIVATE_MILESTONE, + CLOSE_MILESTONE, + + // Tickets management + CREATE_TICKET, + ASSIGN_TICKET_DEVELOPER, + CHECK_TICKET_COMPLETION, + + // Tickets execution + TICKET_ACCEPT, + TICKET_START, + TICKET_COMPLETE, + + // Bugs + CREATE_BUG_REPORT, + FIX_BUG_REPORT, + TEST_BUG_REPORT, + CLOSE_BUG_REPORT +} diff --git a/src/main/java/org/lab/app/Presenter.java b/src/main/java/org/lab/app/Presenter.java new file mode 100644 index 0000000..508c34e --- /dev/null +++ b/src/main/java/org/lab/app/Presenter.java @@ -0,0 +1,245 @@ +package org.lab.app; + +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; +import org.lab.domain.enums.BugStatus; +import org.lab.domain.enums.ProjectRole; +import org.lab.domain.enums.TicketStatus; + +import java.util.Objects; + +public final class Presenter { + + private Presenter() { + } + + /** + * Modern Java: + * - Text Blocks ("""..."""): многострочное человекочитаемое сообщение без конкатенации строк. + * - String::formatted: безопасная подстановка значений (альтернатива “строковым шаблонам”, которых нет в JDK 26). + */ + public static String projectCreated(ProjectView project) { + Objects.requireNonNull(project, "project"); + return """ + Project created: + key=%s + id=%s + name=%s + manager=%s + teamLead=%s + myRole=%s + """.formatted( + project.key(), + project.id(), + project.name(), + project.managerId(), + project.teamLeadId(), + project.myRole() + ); + } + + /** + * Modern Java: + * - Text Blocks ("""...""") + formatted(): удобный вывод структурированных данных для CLI/логов. + */ + public static String milestoneCreated(MilestoneView milestone) { + Objects.requireNonNull(milestone, "milestone"); + return """ + Milestone created: + id=%s + project=%s + name=%s + range=%s..%s + status=%s + """.formatted( + milestone.id(), + milestone.projectId(), + milestone.name(), + milestone.start(), + milestone.end(), + milestone.status() + ); + } + + /** + * Modern Java: + * - Text Blocks ("""...""") + formatted(): читаемое сообщение о назначении роли без ручной склейки строк. + */ + public static String roleAssigned(ProjectId projectId, UserId userId, ProjectRole role) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(role, "role"); + return """ + Role assigned: + project=%s + user=%s + role=%s + """.formatted(projectId, userId, role); + } + + public static String ticketCreated(TicketView ticket) { + Objects.requireNonNull(ticket, "ticket"); + return """ + Ticket created: + id=%s + project=%s + milestone=%s + title="%s" + status=%s + assignees=%s + """.formatted( + ticket.id(), + ticket.projectId(), + ticket.milestoneId(), + ticket.title(), + ticket.status(), + ticket.assignees() + ); + } + + /** + * Modern Java: + * - Text Blocks ("""...""") + formatted(): структурированное сообщение о bug report. + */ + public static String bugReportCreated(BugReportView bug) { + Objects.requireNonNull(bug, "bug"); + return """ + Bug report created: + id=%s + project=%s + title="%s" + status=%s + assignedTo=%s + """.formatted( + bug.id(), + bug.projectId(), + bug.title(), + bug.status(), + bug.assignedTo() + ); + } + + /** + * Modern Java: + * - Text Blocks ("""...""") + formatted(): фиксирует переход статуса в формате “FROM -> TO”. + */ + public static String bugReportStatusChanged(BugReportView bug, BugStatus from) { + Objects.requireNonNull(bug, "bug"); + Objects.requireNonNull(from, "from"); + return """ + Bug report status changed: + id=%s + %s -> %s + assignedTo=%s + """.formatted( + bug.id(), + from, + bug.status(), + bug.assignedTo() + ); + } + + /** + * Modern Java: + * - Text Blocks ("""...""") + formatted(): фиксирует переход статуса тикета “FROM -> TO”. + */ + public static String ticketStatusChanged(TicketView ticket, TicketStatus from) { + Objects.requireNonNull(ticket, "ticket"); + Objects.requireNonNull(from, "from"); + return """ + Ticket status changed: + id=%s + %s -> %s + assignees=%s + """.formatted( + ticket.id(), + from, + ticket.status(), + ticket.assignees() + ); + } + /** + * Modern Java: + * - Расширенная стандартная библиотека: StringBuilder для эффективной сборки большого вывода без лишних аллокаций. + * - Использование records/view-объектов: читает данные через accessor-методы record’ов (d.projects(), d.tickets()...). + */ + public static String dashboard(String actorLogin, DashboardView d) { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(d, "d"); + + var sb = new StringBuilder(); + sb.append("DASHBOARD for ").append(actorLogin).append(" (").append(d.userId()).append(")\n\n"); + + sb.append("Projects: ").append(d.projects().size()).append("\n"); + for (var pr : d.projects()) { + sb.append(" - ").append(pr.key()).append(" | ").append(pr.name()) + .append(" | myRole=").append(pr.myRole()) + .append(" | milestones=").append(pr.milestonesCount()) + .append(" | tickets=").append(pr.ticketsCount()) + .append(" | bugs=").append(pr.bugReportsCount()) + .append("\n"); + } + + sb.append("\nMy tickets: ").append(d.tickets().size()).append("\n"); + for (var tv : d.tickets()) { + sb.append(" - ").append(tv.id()).append(" | ").append(tv.title()) + .append(" | ").append(tv.status()) + .append(" | project=").append(tv.projectId()) + .append("\n"); + } + + sb.append("\nActionable bugs (fix/check): ").append(d.actionableBugs().size()).append("\n"); + for (var bv : d.actionableBugs()) { + sb.append(" - ").append(bv.id()).append(" | ").append(bv.title()) + .append(" | ").append(bv.status()) + .append(" | assignedTo=").append(bv.assignedTo()) + .append(" | project=").append(bv.projectId()) + .append("\n"); + } + + return sb.toString(); + } + + /** + * Modern Java: + * - Sealed Result + “алгебраический” API: использует result.match(ok -> ..., failure -> ...) + * вместо try/catch и проверки типов исключений. + */ + public static String result(String operationName, Result result) { + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(result, "result"); + return result.match( + ok -> "OK: " + operationName, + failure -> failureMessage(operationName, failure) + ); + } + + public static String failureMessage(String operationName, FailureCause failure) { + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(failure, "failure"); + + return switch (failure) { + case FailureCause.Domain(var error) -> """ + FAIL: %s + type=DOMAIN + code=%s + message=%s + """.formatted(operationName, error.code(), error.userMessage()); + + case AccessDenied denied -> """ + FAIL: %s + type=ACCESS_DENIED + actor=%s + project=%s + role=%s + operation=%s + """.formatted( + operationName, + denied.actorId(), + denied.projectId(), + denied.role(), + denied.operation() + ); + }; + } +} diff --git a/src/main/java/org/lab/app/ProjectManagementService.java b/src/main/java/org/lab/app/ProjectManagementService.java new file mode 100644 index 0000000..5baec2a --- /dev/null +++ b/src/main/java/org/lab/app/ProjectManagementService.java @@ -0,0 +1,605 @@ +package org.lab.app; + +import org.lab.domain.*; +import org.lab.domain.enums.BugStatus; +import org.lab.infra.BugReportRepository; +import org.lab.infra.ProjectRepository; +import org.lab.infra.TicketRepository; +import org.lab.infra.UserRepository; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.concurrent.StructuredTaskScope; + +public final class ProjectManagementService { + + private final UserRepository users; + private final ProjectRepository projects; + private final TicketRepository tickets; + private final BugReportRepository bugs; + private final Clock clock; + + public ProjectManagementService(UserRepository users, + ProjectRepository projects, + TicketRepository tickets, + BugReportRepository bugs) { + this(users, projects, tickets, bugs, Clock.systemUTC()); + } + + public ProjectManagementService(UserRepository users, + ProjectRepository projects, + TicketRepository tickets, + BugReportRepository bugs, + Clock clock) { + this.users = Objects.requireNonNull(users, "users"); + this.projects = Objects.requireNonNull(projects, "projects"); + this.tickets = Objects.requireNonNull(tickets, "tickets"); + this.bugs = Objects.requireNonNull(bugs, "bugs"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + private Instant now() { + return clock.instant(); + } + + // ---------------- Common for all users ---------------- + + public Result register(String login, String displayName) { + var id = users.nextId(); + var ts = now(); + + return fromDomain(User.register(id, login, displayName, ts)) + .flatMap(u -> fromDomain(users.insert(u))) + .map(u -> new UserView(u.id(), u.login(), u.displayName())); + } + + public Result createProject(UserId creatorId, String name, String description) { + Objects.requireNonNull(creatorId, "creatorId"); + + var userCheck = ensureUserExists(creatorId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + var id = projects.nextId(); + var key = projects.nextProjectKey(); + var ts = now(); + + return fromDomain(Project.create(id, key, name, description, creatorId, ts)) + .flatMap(p -> fromDomain(projects.insert(p))) + .map(p -> toProjectView(p, creatorId)); + } + + public Result> listMyProjects(UserId userId) { + Objects.requireNonNull(userId, "userId"); + + var userCheck = ensureUserExists(userId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + var list = projects.findByMember(userId).stream() + .map(p -> toProjectView(p, userId)) + .collect(Collectors.toUnmodifiableList()); + + return Result.ok(list); + } + + public Result> listMyTickets(UserId userId) { + Objects.requireNonNull(userId, "userId"); + + var userCheck = ensureUserExists(userId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + var list = tickets.findByAssignee(userId).stream() + .map(this::toTicketView) + .collect(Collectors.toUnmodifiableList()); + + return Result.ok(list); + } + + public Result> listBugsToFix(UserId userId) { + Objects.requireNonNull(userId, "userId"); + + var userCheck = ensureUserExists(userId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + var list = bugs.findToFix(userId).stream() + .map(this::toBugView) + .collect(Collectors.toUnmodifiableList()); + + return Result.ok(list); + } + + /** + * Modern Java: + * - Structured Concurrency (preview): использует StructuredTaskScope.open() для параллельного fork/join трёх задач + * (проекты, тикеты, actionable-bugs) как единого блока работ с корректным join и обработкой InterruptedException. + * - Pattern matching for switch: в обработке FailedException разбирает причину через switch с type pattern + * (case TaskFailure tf -> ...), без ручных instanceof/кастов. + * - Sealed-результат: возвращает Result (Success/Failure), т.е. типизированная модель успеха/ошибки + * вместо исключений как механизма бизнес-ошибок. + */ + + public Result buildDashboard(UserId userId) { + Objects.requireNonNull(userId, "userId"); + + var userCheck = ensureUserExists(userId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + try (var scope = StructuredTaskScope.open()) { + var projectsTask = scope.fork(() -> unwrap(listMyProjects(userId))); + var ticketsTask = scope.fork(() -> unwrap(listMyTickets(userId))); + var bugsTask = scope.fork(() -> unwrap(listActionableBugs(userId))); + + scope.join(); // propagates failures (throws FailedException) + + var view = new DashboardView( + userId, + projectsTask.get(), + ticketsTask.get(), + bugsTask.get() + ); + return Result.ok(view); + + } catch (StructuredTaskScope.FailedException e) { + var cause = e.getCause(); + + return switch (cause) { + case TaskFailure tf -> Result.fail(tf.cause()); + default -> Result.fail(new FailureCause.Domain( + new DomainError.InvariantViolation("dashboard.failed", "Unexpected failure: " + cause) + )); + }; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Result.fail(new FailureCause.Domain( + new DomainError.InvariantViolation("dashboard.interrupted", "Thread interrupted while building dashboard") + )); + } + } + + /** + * Modern Java: + * - Функциональный стиль (Stream API): собирает список через stream/flatMap/map/Collectors.toUnmodifiableList(). + * - Расширения стандартной библиотеки: использует Collectors.toUnmodifiableList() для неизменяемого результата. + * - Sealed-результат: возвращает Result>, сохраняя ошибки домена/доступа в типе результата. + */ + + private Result> listActionableBugs(UserId userId) { + Objects.requireNonNull(userId, "userId"); + + var userCheck = ensureUserExists(userId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + var memberProjects = projects.findByMember(userId); + + var list = memberProjects.stream() + .flatMap(p -> bugsForRole(p, userId)) + .map(this::toBugView) + .collect(Collectors.toUnmodifiableList()); + + return Result.ok(list); + } + + /** + * Modern Java: + * - Switch expression: возвращает значение (Stream) непосредственно из switch по роли. + * - В сочетании с enum-статусами и Stream API показывает “выражаемую” бизнес-логику без if/else-цепочек. + */ + private Stream bugsForRole(Project p, UserId userId) { + var roleOpt = p.roleOf(userId); + if (roleOpt.isEmpty()) { + return Stream.empty(); + } + + var role = roleOpt.get(); + return switch (role) { + case DEVELOPER -> p.bugReports().values().stream() + .filter(b -> b.status() == BugStatus.NEW) + .filter(b -> userId.equals(b.assignedTo())); + + case TESTER -> p.bugReports().values().stream() + .filter(b -> b.status() == BugStatus.FIXED); + + case MANAGER, TEAM_LEAD -> Stream.empty(); + }; + } + + /** + * Modern Java: + * - Sealed Result + функциональная модель ошибок: превращает Result в значение либо кидает доменный TaskFailure. + * - Используется совместно со structured concurrency, чтобы пробросить Failure из подзадачи через исключение + * (технический мост между Result-моделью и механизмом FailedException у StructuredTaskScope). + */ + private static T unwrap(Result r) { + if (r.isSuccess()) { + return r.toOptional().orElseThrow(); + } + throw new TaskFailure(Objects.requireNonNull(r.failureOrNull(), "failure")); + } + + private static final class TaskFailure extends RuntimeException { + private final FailureCause cause; + + private TaskFailure(FailureCause cause) { + super(cause.code() + ": " + cause.message()); + this.cause = cause; + } + + public FailureCause cause() { + return cause; + } + } + + public Result addDeveloper(UserId actorId, ProjectId projectId, UserId developerId) { + return withProjectAndPermission(actorId, projectId, Operation.ADD_DEVELOPER) + .flatMap(ctx -> ensureUserExists(developerId) + .flatMap(ignored -> + fromDomain(projects.update(projectId, p -> p.addDeveloper(developerId, now()))) + .map(updated -> toProjectView(updated, actorId)) + )); + } + + public Result addTester(UserId actorId, ProjectId projectId, UserId testerId) { + return withProjectAndPermission(actorId, projectId, Operation.ADD_TESTER) + .flatMap(ctx -> ensureUserExists(testerId) + .flatMap(ignored -> + fromDomain(projects.update(projectId, p -> p.addTester(testerId, now()))) + .map(updated -> toProjectView(updated, actorId)) + )); + } + + public Result createMilestone(UserId actorId, + ProjectId projectId, + String milestoneName, + LocalDate start, + LocalDate end) { + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + + return withProjectAndPermission(actorId, projectId, Operation.CREATE_MILESTONE) + .flatMap(ctx -> fromDomain(DateRange.of(start, end)) + .flatMap(range -> { + var mid = MilestoneId.newId(); + return fromDomain(projects.update(projectId, p -> p.createMilestone(mid, milestoneName, range, now()))) + .flatMap(updated -> { + var ms = updated.milestones().get(mid); + if (ms == null) { + return Result.fail(new FailureCause.Domain( + new DomainError.InvariantViolation("milestone.created", "milestone not found after creation") + )); + } + return Result.ok(toMilestoneView(ms)); + }); + })); + } + + public Result activateMilestone(UserId actorId, ProjectId projectId, MilestoneId milestoneId) { + return withProjectAndPermission(actorId, projectId, Operation.ACTIVATE_MILESTONE) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.activateMilestone(milestoneId, now())))) + .flatMap(updated -> { + var ms = updated.milestones().get(milestoneId); + if (ms == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Milestone", milestoneId.toString()))); + } + return Result.ok(toMilestoneView(ms)); + }); + } + + public Result closeMilestone(UserId actorId, ProjectId projectId, MilestoneId milestoneId) { + return withProjectAndPermission(actorId, projectId, Operation.CLOSE_MILESTONE) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.closeMilestone(milestoneId, now())))) + .flatMap(updated -> { + var ms = updated.milestones().get(milestoneId); + if (ms == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Milestone", milestoneId.toString()))); + } + return Result.ok(toMilestoneView(ms)); + }); + } + + public Result createTicket(UserId actorId, + ProjectId projectId, + MilestoneId milestoneId, + String title, + String description) { + return withProjectAndPermission(actorId, projectId, Operation.CREATE_TICKET) + .flatMap(ctx -> fromDomain(Title.of(title)) + .flatMap(t -> fromDomain(Description.of(description)) + .flatMap(d -> { + var tid = tickets.nextId(); + return fromDomain(projects.update(projectId, p -> p.createTicket(tid, milestoneId, t, d, actorId, now()))) + .flatMap(updated -> { + var ticket = updated.tickets().get(tid); + if (ticket == null) { + return Result.fail(new FailureCause.Domain( + new DomainError.InvariantViolation("ticket.created", "ticket not found after creation") + )); + } + var up = fromDomain(tickets.upsert(ticket)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toTicketView(ticket)); + }); + }))); + } + + public Result assignDeveloperToTicket(UserId actorId, + ProjectId projectId, + TicketId ticketId, + UserId developerId) { + return withProjectAndPermission(actorId, projectId, Operation.ASSIGN_TICKET_DEVELOPER) + .flatMap(ctx -> ensureUserExists(developerId) + .flatMap(ignored -> + fromDomain(projects.update(projectId, p -> p.assignDeveloperToTicket(ticketId, developerId, now()))) + .flatMap(updated -> { + var ticket = updated.tickets().get(ticketId); + if (ticket == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Ticket", ticketId.toString()))); + } + var up = fromDomain(tickets.upsert(ticket)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toTicketView(ticket)); + }) + )); + } + + public Result checkTicketCompletion(UserId actorId, ProjectId projectId, TicketId ticketId) { + return withProjectAndPermission(actorId, projectId, Operation.CHECK_TICKET_COMPLETION) + .flatMap(ctx -> getProject(projectId)) + .flatMap(p -> { + var ticket = p.tickets().get(ticketId); + if (ticket == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Ticket", ticketId.toString()))); + } + return Result.ok(new TicketCompletionView(ticket.id(), ticket.status(), ticket.isDone())); + }); + } + + public Result acceptTicket(UserId actorId, ProjectId projectId, TicketId ticketId) { + return withProjectAndPermission(actorId, projectId, Operation.TICKET_ACCEPT) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyTicketAction(ticketId, new TicketAction.Accept(actorId), now())))) + .flatMap(updated -> { + var ticket = updated.tickets().get(ticketId); + if (ticket == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Ticket", ticketId.toString()))); + } + var up = fromDomain(tickets.upsert(ticket)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toTicketView(ticket)); + }); + } + + public Result startTicket(UserId actorId, ProjectId projectId, TicketId ticketId) { + return withProjectAndPermission(actorId, projectId, Operation.TICKET_START) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyTicketAction(ticketId, new TicketAction.Start(actorId), now())))) + .flatMap(updated -> { + var ticket = updated.tickets().get(ticketId); + if (ticket == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Ticket", ticketId.toString()))); + } + var up = fromDomain(tickets.upsert(ticket)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toTicketView(ticket)); + }); + } + + public Result completeTicket(UserId actorId, ProjectId projectId, TicketId ticketId) { + return withProjectAndPermission(actorId, projectId, Operation.TICKET_COMPLETE) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyTicketAction(ticketId, new TicketAction.Complete(actorId), now())))) + .flatMap(updated -> { + var ticket = updated.tickets().get(ticketId); + if (ticket == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("Ticket", ticketId.toString()))); + } + var up = fromDomain(tickets.upsert(ticket)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toTicketView(ticket)); + }); + } + + + public Result createBugReport(UserId actorId, + ProjectId projectId, + String title, + String description) { + return withProjectAndPermission(actorId, projectId, Operation.CREATE_BUG_REPORT) + .flatMap(ctx -> fromDomain(Title.of(title)) + .flatMap(t -> fromDomain(Description.of(description)) + .flatMap(d -> { + var bid = bugs.nextId(); + return fromDomain(projects.update(projectId, p -> p.createBugReport(bid, t, d, actorId, now()))) + .flatMap(updated -> { + var bug = updated.bugReports().get(bid); + if (bug == null) { + return Result.fail(new FailureCause.Domain( + new DomainError.InvariantViolation("bug.created", "bug report not found after creation") + )); + } + var up = fromDomain(bugs.upsert(bug)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toBugView(bug)); + }); + }))); + } + + public Result fixBugReport(UserId actorId, ProjectId projectId, BugReportId bugId) { + return withProjectAndPermission(actorId, projectId, Operation.FIX_BUG_REPORT) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyBugReportAction(bugId, new BugReportAction.Fix(actorId), now())))) + .flatMap(updated -> { + var bug = updated.bugReports().get(bugId); + if (bug == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("BugReport", bugId.toString()))); + } + var up = fromDomain(bugs.upsert(bug)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toBugView(bug)); + }); + } + + public Result testBugReport(UserId actorId, ProjectId projectId, BugReportId bugId) { + return withProjectAndPermission(actorId, projectId, Operation.TEST_BUG_REPORT) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyBugReportAction(bugId, new BugReportAction.Test(actorId), now())))) + .flatMap(updated -> { + var bug = updated.bugReports().get(bugId); + if (bug == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("BugReport", bugId.toString()))); + } + var up = fromDomain(bugs.upsert(bug)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toBugView(bug)); + }); + } + + public Result closeBugReport(UserId actorId, ProjectId projectId, BugReportId bugId) { + return withProjectAndPermission(actorId, projectId, Operation.CLOSE_BUG_REPORT) + .flatMap(ctx -> fromDomain(projects.update(projectId, p -> p.applyBugReportAction(bugId, new BugReportAction.Close(actorId), now())))) + .flatMap(updated -> { + var bug = updated.bugReports().get(bugId); + if (bug == null) { + return Result.fail(new FailureCause.Domain(new DomainError.NotFound("BugReport", bugId.toString()))); + } + var up = fromDomain(bugs.upsert(bug)); + if (up.isFailure()) { + return Result.fail(up.failureOrNull()); + } + return Result.ok(toBugView(bug)); + }); + } + + // ---------------- Internal helpers ---------------- + + private Result ensureUserExists(UserId userId) { + Objects.requireNonNull(userId, "userId"); + return users.findById(userId) + .>map(u -> Result.ok(Unit.INSTANCE)) + .orElseGet(() -> Result.fail(new FailureCause.Domain(new DomainError.NotFound("User", userId.toString())))); + } + + private Result getProject(ProjectId projectId) { + Objects.requireNonNull(projectId, "projectId"); + return projects.findById(projectId) + .>map(Result::ok) + .orElseGet(() -> Result.fail(new FailureCause.Domain(new DomainError.NotFound("Project", projectId.toString())))); + } + + private record ProjectContext(Project project, ActorRole role) { } + + private Result getContext(UserId actorId, ProjectId projectId) { + Objects.requireNonNull(actorId, "actorId"); + Objects.requireNonNull(projectId, "projectId"); + + return getProject(projectId).map(p -> { + var roleOpt = p.roleOf(actorId); + var ar = ActorRole.from(roleOpt); + return new ProjectContext(p, ar); + }); + } + + private Result withProjectAndPermission(UserId actorId, ProjectId projectId, Operation op) { + Objects.requireNonNull(actorId, "actorId"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(op, "op"); + + var userCheck = ensureUserExists(actorId); + if (userCheck.isFailure()) { + return Result.fail(userCheck.failureOrNull()); + } + + return getContext(actorId, projectId).flatMap(ctx -> { + if (!AccessControl.isAllowed(ctx.role(), op)) { + return Result.fail(new AccessDenied(actorId, projectId, op.name(), ctx.role().name())); + } + if (ctx.role() instanceof ActorRole.Outsider) { + return Result.fail(new AccessDenied(actorId, projectId, op.name(), ctx.role().name())); + } + return Result.ok(ctx); + }); + } + + private static Result fromDomain(DomainResult domain) { + Objects.requireNonNull(domain, "domain"); + if (domain.isSuccess()) { + return Result.ok(domain.orElseThrow()); + } + var err = domain.errorOrNull(); + return Result.fail(new FailureCause.Domain(Objects.requireNonNull(err, "domain error"))); + } + + private ProjectView toProjectView(Project p, UserId viewer) { + var role = p.roleOf(viewer).map(Enum::name).orElse("OUTSIDER"); + return new ProjectView( + p.id(), + p.key().value(), + p.name(), + p.managerId(), + p.teamLeadId(), + role, + p.milestones().size(), + p.tickets().size(), + p.bugReports().size() + ); + } + + private MilestoneView toMilestoneView(Milestone m) { + return new MilestoneView( + m.id(), + m.projectId(), + m.name(), + m.range().start(), + m.range().end(), + m.status() + ); + } + + private TicketView toTicketView(Ticket t) { + return new TicketView( + t.id(), + t.projectId(), + t.milestoneId(), + t.title().value(), + t.status(), + t.assignees() + ); + } + + private BugReportView toBugView(BugReport b) { + return new BugReportView( + b.id(), + b.projectId(), + b.title().value(), + b.status(), + b.assignedTo() + ); + } +} diff --git a/src/main/java/org/lab/app/ProjectView.java b/src/main/java/org/lab/app/ProjectView.java new file mode 100644 index 0000000..be11e31 --- /dev/null +++ b/src/main/java/org/lab/app/ProjectView.java @@ -0,0 +1,16 @@ +package org.lab.app; + +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; + +public record ProjectView( + ProjectId id, + String key, + String name, + UserId managerId, + UserId teamLeadId, + String myRole, + int milestonesCount, + int ticketsCount, + int bugReportsCount +) { } diff --git a/src/main/java/org/lab/app/Result.java b/src/main/java/org/lab/app/Result.java new file mode 100644 index 0000000..3d18780 --- /dev/null +++ b/src/main/java/org/lab/app/Result.java @@ -0,0 +1,89 @@ +package org.lab.app; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +public sealed interface Result permits Result.Success, Result.Failure { + + boolean isSuccess(); + + default boolean isFailure() { + return !isSuccess(); + } + + Optional toOptional(); + + FailureCause failureOrNull(); + + static Result ok(T value) { + return new Success<>(Objects.requireNonNull(value, "value")); + } + + static Result fail(FailureCause cause) { + return new Failure<>(Objects.requireNonNull(cause, "cause")); + } + + @SuppressWarnings("unchecked") + default U match(Function onSuccess, + Function onFailure) { + Objects.requireNonNull(onSuccess, "onSuccess"); + Objects.requireNonNull(onFailure, "onFailure"); + + // pattern matching switch №1 (record patterns over sealed Result) + return switch (this) { + case Success(var value) -> onSuccess.apply((T) value); + case Failure(var cause) -> onFailure.apply(cause); + }; + } + + default Result map(Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return match( + v -> Result.ok(Objects.requireNonNull(mapper.apply(v), "mapper result")), + Result::fail + ); + } + + default Result flatMap(Function> mapper) { + Objects.requireNonNull(mapper, "mapper"); + return match( + v -> Objects.requireNonNull(mapper.apply(v), "mapper result"), + Result::fail + ); + } + + record Success(T value) implements Result { + @Override + public boolean isSuccess() { + return true; + } + + @Override + public Optional toOptional() { + return Optional.of(value); + } + + @Override + public FailureCause failureOrNull() { + return null; + } + } + + record Failure(FailureCause cause) implements Result { + @Override + public boolean isSuccess() { + return false; + } + + @Override + public Optional toOptional() { + return Optional.empty(); + } + + @Override + public FailureCause failureOrNull() { + return cause; + } + } +} diff --git a/src/main/java/org/lab/app/TicketCompletionView.java b/src/main/java/org/lab/app/TicketCompletionView.java new file mode 100644 index 0000000..b49f7d4 --- /dev/null +++ b/src/main/java/org/lab/app/TicketCompletionView.java @@ -0,0 +1,10 @@ +package org.lab.app; + +import org.lab.domain.TicketId; +import org.lab.domain.enums.TicketStatus; + +public record TicketCompletionView( + TicketId ticketId, + TicketStatus status, + boolean done +) { } diff --git a/src/main/java/org/lab/app/TicketView.java b/src/main/java/org/lab/app/TicketView.java new file mode 100644 index 0000000..86f18f2 --- /dev/null +++ b/src/main/java/org/lab/app/TicketView.java @@ -0,0 +1,19 @@ +package org.lab.app; + + +import org.lab.domain.MilestoneId; +import org.lab.domain.ProjectId; +import org.lab.domain.TicketId; +import org.lab.domain.UserId; +import org.lab.domain.enums.TicketStatus; + +import java.util.Set; + +public record TicketView( + TicketId id, + ProjectId projectId, + MilestoneId milestoneId, + String title, + TicketStatus status, + Set assignees +) { } diff --git a/src/main/java/org/lab/app/Unit.java b/src/main/java/org/lab/app/Unit.java new file mode 100644 index 0000000..70732ab --- /dev/null +++ b/src/main/java/org/lab/app/Unit.java @@ -0,0 +1,9 @@ +package org.lab.app; + +/** + * Non-null replacement for Void in Result. + * Use Unit.INSTANCE when operation succeeds but returns no payload. + */ +public enum Unit { + INSTANCE +} diff --git a/src/main/java/org/lab/app/UserView.java b/src/main/java/org/lab/app/UserView.java new file mode 100644 index 0000000..064e588 --- /dev/null +++ b/src/main/java/org/lab/app/UserView.java @@ -0,0 +1,9 @@ +package org.lab.app; + +import org.lab.domain.UserId; + +public record UserView( + UserId id, + String login, + String displayName +) { } diff --git a/src/main/java/org/lab/cli/CliMain.java b/src/main/java/org/lab/cli/CliMain.java new file mode 100644 index 0000000..959b97c --- /dev/null +++ b/src/main/java/org/lab/cli/CliMain.java @@ -0,0 +1,183 @@ +package org.lab.cli; + +import org.lab.app.ProjectManagementService; +import org.lab.app.Result; +import org.lab.infra.BugReportRepository; +import org.lab.infra.ProjectRepository; +import org.lab.infra.TicketRepository; +import org.lab.infra.UserRepository; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public final class CliMain { + + /** + * Modern Java: + * - Расширенная стандартная библиотека: сборка приложения без фреймворков, wiring слоёв вручную. + * - Демонстрирует in-memory инфраструктуру (ConcurrentHashMap в репозиториях) и запуск CLI без сервера/БД. + */ + public static void main(String[] args) throws Exception { + var users = new UserRepository(); + var projects = new ProjectRepository(); + var tickets = new TicketRepository(); + var bugs = new BugReportRepository(); + + var service = new ProjectManagementService(users, projects, tickets, bugs); + var state = new CliState(); + var runner = new CliRunner(service, users, projects, state); + + System.out.println("=== Project Management CLI (Java 26) ==="); + System.out.println("Type: help | demo | exit"); + System.out.println(); + + boolean ranDemo = false; + for (var a : args) { + if ("--demo".equalsIgnoreCase(a)) { + runDemo(runner); + ranDemo = true; + break; + } + } + + if (!ranDemo) { + runDemo(runner); + } + + runRepl(runner); + } + + /** + * Modern Java: + * - Try-with-resources: корректное управление ресурсами ввода (BufferedReader). + * - StandardCharsets.UTF_8: явная кодировка ввода из стандартной библиотеки (важно для Windows/консоли). + */ + private static void runRepl(CliRunner runner) throws Exception { + try (var br = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) { + while (true) { + System.out.print("> "); + var line = br.readLine(); + if (line == null) { + System.out.println(); + return; + } + var trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + + if ("exit".equalsIgnoreCase(trimmed) || "quit".equalsIgnoreCase(trimmed)) { + return; + } + if ("help".equalsIgnoreCase(trimmed)) { + printHelp(); + continue; + } + if ("demo".equalsIgnoreCase(trimmed)) { + runDemo(runner); + continue; + } + + executeLine(runner, trimmed); + } + } + } + + /** + * Modern Java: + * - Pattern matching for switch: switch по sealed Parsed (Ok/Error) с record-pattern’ами + * (case Parsed.Ok(var cmd) -> ..., case Parsed.Error(var msg) -> ...). + * - Sealed Result: печатает успех/ошибку, не используя исключения как бизнес-модель. + */ + private static void executeLine(CliRunner runner, String line) { + var parsed = CommandParser.parse(line); + switch (parsed) { + case CommandParser.Parsed.Ok(var cmd) -> { + var result = runner.execute(cmd); + + if (result.isSuccess()) { + System.out.println(result.toOptional().orElse("")); + } else { + System.out.println(String.valueOf(result.failureOrNull())); + } + System.out.println(); + System.out.flush(); + + } + case CommandParser.Parsed.Error(var msg) -> System.out.println("Parse error: " + msg); + } + System.out.println(); + } + + /** + * Modern Java: + * - Text Blocks ("""..."""): многострочный help без конкатенации и “\n” по всему коду. + */ + private static void runDemo(CliRunner runner) { + System.out.println("=== DEMO сценарий ==="); + + executeLine(runner, "register manager \"Project Manager\""); + executeLine(runner, "register dev \"Backend Developer\""); + executeLine(runner, "register tester \"QA Tester\""); + + executeLine(runner, "create-project manager \"Demo Project\" \"Project created from CLI demo\""); + executeLine(runner, "dashboard manager"); + + executeLine(runner, "add-dev manager lastProject dev"); + executeLine(runner, "add-tester manager lastProject tester"); + + executeLine(runner, "create-milestone manager lastProject \"Milestone 1\" 2025-12-14 2025-12-21"); + executeLine(runner, "activate-milestone manager lastProject lastMilestone"); + + executeLine(runner, "create-ticket manager lastProject lastMilestone \"Implement feature\" \"Do the implementation\""); + executeLine(runner, "assign-ticket manager lastProject lastTicket dev"); + + executeLine(runner, "start-ticket dev lastProject lastTicket"); + executeLine(runner, "done-ticket dev lastProject lastTicket"); + + executeLine(runner, "create-bug tester lastProject \"Bug #1\" \"Found defect during testing\""); + executeLine(runner, "fix-bug dev lastProject lastBug"); + executeLine(runner, "test-bug tester lastProject lastBug"); + executeLine(runner, "close-bug tester lastProject lastBug"); + + executeLine(runner, "dashboard manager"); + + System.out.println("=== DEMO завершён ==="); + System.out.println(); + } + + private static void printHelp() { + System.out.println(""" + Commands: + register "Display Name" + + create-project "Project Name" "Description" + projectRef can be: project KEY (e.g. PRJ-000001) | UUID | lastProject | last + + add-dev + add-tester + + create-milestone "Milestone Name" + activate-milestone + milestoneRef: UUID | lastMilestone | last + + create-ticket "Title" "Description" + assign-ticket + start-ticket + done-ticket + ticketRef: UUID | lastTicket | last + + create-bug "Title" "Description" + fix-bug + test-bug + close-bug + bugRef: UUID | lastBug | last + + dashboard + + Meta: + demo | help | exit + """); + } +} diff --git a/src/main/java/org/lab/cli/CliRunner.java b/src/main/java/org/lab/cli/CliRunner.java new file mode 100644 index 0000000..2db243a --- /dev/null +++ b/src/main/java/org/lab/cli/CliRunner.java @@ -0,0 +1,421 @@ +package org.lab.cli; + +import org.lab.app.Presenter; +import org.lab.app.ProjectManagementService; +import org.lab.app.Result; +import org.lab.domain.BugReportId; +import org.lab.domain.DomainError; +import org.lab.domain.MilestoneId; +import org.lab.domain.ProjectId; +import org.lab.domain.TicketId; +import org.lab.domain.UserId; +import org.lab.domain.enums.BugStatus; +import org.lab.domain.enums.ProjectRole; +import org.lab.domain.enums.TicketStatus; +import org.lab.infra.ProjectRepository; +import org.lab.infra.UserRepository; + +import java.util.Locale; +import java.util.Objects; + +public final class CliRunner { + + private final ProjectManagementService service; + private final UserRepository users; + private final ProjectRepository projects; + private final CliState state; + + public CliRunner(ProjectManagementService service, UserRepository users, ProjectRepository projects, CliState state) { + this.service = Objects.requireNonNull(service, "service"); + this.users = Objects.requireNonNull(users, "users"); + this.projects = Objects.requireNonNull(projects, "projects"); + this.state = Objects.requireNonNull(state, "state"); + } + + /** + * Modern Java: + * - Pattern matching for switch: switch по sealed Command с record-pattern’ами + * (case Command.Register(var login, var displayName) -> ...). + * - Sealed Result: единый тип результата для всех команд (успех/ошибка) вместо исключений. + */ + public Result execute(Command command) { + Objects.requireNonNull(command, "command"); + + return switch (command) { + case Command.Register(var login, var displayName) -> execRegister(login, displayName); + case Command.CreateProject(var actorLogin, var name, var description) -> execCreateProject(actorLogin, name, description); + case Command.AddDev(var actorLogin, var projectRef, var memberLogin, var role) -> execAddMember(actorLogin, projectRef, memberLogin, role); + + case Command.CreateMilestone(var actorLogin, var projectRef, var name, var start, var end) -> + execCreateMilestone(actorLogin, projectRef, name, start, end); + + case Command.ActivateMilestone(var actorLogin, var projectRef, var milestoneRef) -> + execActivateMilestone(actorLogin, projectRef, milestoneRef); + + case Command.CreateTicket(var actorLogin, var projectRef, var milestoneRef, var title, var description) -> + execCreateTicket(actorLogin, projectRef, milestoneRef, title, description); + + case Command.AssignTicket(var actorLogin, var projectRef, var ticketRef, var developerLogin) -> + execAssignTicket(actorLogin, projectRef, ticketRef, developerLogin); + + case Command.StartTicket(var actorLogin, var projectRef, var ticketRef) -> + execStartTicket(actorLogin, projectRef, ticketRef); + + case Command.DoneTicket(var actorLogin, var projectRef, var ticketRef) -> + execDoneTicket(actorLogin, projectRef, ticketRef); + + case Command.CreateBug(var actorLogin, var projectRef, var title, var description) -> + execCreateBug(actorLogin, projectRef, title, description); + + case Command.FixBug(var actorLogin, var projectRef, var bugRef) -> + execFixBug(actorLogin, projectRef, bugRef); + + case Command.TestBug(var actorLogin, var projectRef, var bugRef) -> + execTestBug(actorLogin, projectRef, bugRef); + + case Command.CloseBug(var actorLogin, var projectRef, var bugRef) -> + execCloseBug(actorLogin, projectRef, bugRef); + + case Command.Dashboard(var actorLogin) -> + execDashboard(actorLogin); + }; + } + + private Result execRegister(String login, String displayName) { + var res = service.register(login, displayName); + return res.map(u -> { + state.rememberUser(u.login(), u.id()); + return "Registered: login=" + u.login() + ", id=" + u.id(); + }); + } + + private Result execCreateProject(String actorLogin, String name, String description) { + return resolveUser(actorLogin) + .flatMap(actorId -> service.createProject(actorId, name, description)) + .map(pv -> { + state.rememberProject(pv.id(), pv.key()); + return Presenter.projectCreated(pv); + }); + } + + /** + * Modern Java: + * - Switch expression: выбирает ветку выполнения по нормализованной роли и возвращает Result прямо из switch. + * - Функциональный стиль: композиция операций через flatMap/map (Result-монадоподобный API). + */ + private Result execAddMember(String actorLogin, String projectRef, String memberLogin, String roleRaw) { + var role = normalizeRole(roleRaw); + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveUser(memberLogin).flatMap(memberId -> { + return switch (role) { + case DEVELOPER -> service.addDeveloper(actorId, projectId, memberId) + .map(updated -> Presenter.roleAssigned(projectId, memberId, ProjectRole.DEVELOPER)); + case TESTER -> service.addTester(actorId, projectId, memberId) + .map(updated -> Presenter.roleAssigned(projectId, memberId, ProjectRole.TESTER)); + }; + }) + ) + ); + } + + private Result execCreateMilestone(String actorLogin, String projectRef, String name, java.time.LocalDate start, java.time.LocalDate end) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + service.createMilestone(actorId, projectId, name, start, end) + .map(ms -> { + state.rememberMilestone(projectId, ms.id()); + return Presenter.milestoneCreated(ms); + }) + ) + ); + } + + private Result execActivateMilestone(String actorLogin, String projectRef, String milestoneRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveMilestoneId(projectId, milestoneRef).flatMap(mid -> + service.activateMilestone(actorId, projectId, mid) + .map(Presenter::milestoneCreated) + ) + ) + ); + } + + private Result execCreateTicket(String actorLogin, String projectRef, String milestoneRef, String title, String description) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveMilestoneId(projectId, milestoneRef).flatMap(mid -> + service.createTicket(actorId, projectId, mid, title, description) + .map(tv -> { + state.rememberTicket(projectId, tv.id()); + return Presenter.ticketCreated(tv); + }) + ) + ) + ); + } + + private Result execAssignTicket(String actorLogin, String projectRef, String ticketRef, String developerLogin) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveTicketId(projectId, ticketRef).flatMap(tid -> + resolveUser(developerLogin).flatMap(devId -> + service.assignDeveloperToTicket(actorId, projectId, tid, devId) + .map(Presenter::ticketCreated) + ) + ) + ) + ); + } + + /** + * Modern Java: + * - Switch expression по TicketStatus: реализует переходы статуса (NEW/ACCEPTED/IN_PROGRESS/DONE) + * как выражение, возвращающее Result, без вложенных if/else. + * - Функциональный стиль: цепочки flatMap/map для последовательных бизнес-операций. + */ + private Result execStartTicket(String actorLogin, String projectRef, String ticketRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveTicketId(projectId, ticketRef).flatMap(tid -> { + var current = loadTicketStatus(projectId, tid); + if (current == null) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Ticket", tid.toString()))); + } + + return switch (current) { + case NEW -> service.acceptTicket(actorId, projectId, tid) + .flatMap(afterAccept -> service.startTicket(actorId, projectId, tid) + .map(afterStart -> Presenter.ticketStatusChanged(afterStart, TicketStatus.NEW))); + case ACCEPTED -> service.startTicket(actorId, projectId, tid) + .map(afterStart -> Presenter.ticketStatusChanged(afterStart, TicketStatus.ACCEPTED)); + case IN_PROGRESS -> Result.ok("Ticket already IN_PROGRESS: " + tid); + case DONE -> Result.ok("Ticket already DONE: " + tid); + }; + }) + ) + ); + } + + /** + * Modern Java: + * - Switch expression по TicketStatus: “доводит” тикет до DONE корректной цепочкой шагов. + * - Функциональный стиль: композиция accept/start/complete через flatMap/map (без try/catch). + */ + private Result execDoneTicket(String actorLogin, String projectRef, String ticketRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveTicketId(projectId, ticketRef).flatMap(tid -> { + var current = loadTicketStatus(projectId, tid); + if (current == null) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Ticket", tid.toString()))); + } + + return switch (current) { + case NEW -> service.acceptTicket(actorId, projectId, tid) + .flatMap(a -> service.startTicket(actorId, projectId, tid)) + .flatMap(s -> service.completeTicket(actorId, projectId, tid)) + .map(done -> Presenter.ticketStatusChanged(done, TicketStatus.NEW)); + case ACCEPTED -> service.startTicket(actorId, projectId, tid) + .flatMap(s -> service.completeTicket(actorId, projectId, tid)) + .map(done -> Presenter.ticketStatusChanged(done, TicketStatus.ACCEPTED)); + case IN_PROGRESS -> service.completeTicket(actorId, projectId, tid) + .map(done -> Presenter.ticketStatusChanged(done, TicketStatus.IN_PROGRESS)); + case DONE -> Result.ok("Ticket already DONE: " + tid); + }; + }) + ) + ); + } + + private Result execCreateBug(String actorLogin, String projectRef, String title, String description) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + service.createBugReport(actorId, projectId, title, description) + .map(bv -> { + state.rememberBug(projectId, bv.id()); + return Presenter.bugReportCreated(bv); + }) + ) + ); + } + + private Result execFixBug(String actorLogin, String projectRef, String bugRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveBugId(projectId, bugRef).flatMap(bid -> { + var from = loadBugStatus(projectId, bid); + if (from == null) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("BugReport", bid.toString()))); + } + return service.fixBugReport(actorId, projectId, bid) + .map(bv -> Presenter.bugReportStatusChanged(bv, from)); + }) + ) + ); + } + + private Result execTestBug(String actorLogin, String projectRef, String bugRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveBugId(projectId, bugRef).flatMap(bid -> { + var from = loadBugStatus(projectId, bid); + if (from == null) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("BugReport", bid.toString()))); + } + return service.testBugReport(actorId, projectId, bid) + .map(bv -> Presenter.bugReportStatusChanged(bv, from)); + }) + ) + ); + } + + private Result execCloseBug(String actorLogin, String projectRef, String bugRef) { + return resolveUser(actorLogin).flatMap(actorId -> + resolveProjectId(projectRef).flatMap(projectId -> + resolveBugId(projectId, bugRef).flatMap(bid -> { + var from = loadBugStatus(projectId, bid); + if (from == null) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("BugReport", bid.toString()))); + } + return service.closeBugReport(actorId, projectId, bid) + .map(bv -> Presenter.bugReportStatusChanged(bv, from)); + }) + ) + ); + } + + /** + * Modern Java: + * - Sealed Result + функциональная композиция: resolveUser(...).flatMap(service::buildDashboard).map(Presenter::dashboard). + * - Косвенно демонстрирует structured concurrency, т.к. buildDashboard внутри сервиса выполняется параллельно. + */ + private Result execDashboard(String actorLogin) { + return resolveUser(actorLogin) + .flatMap(actorId -> service.buildDashboard(actorId)) + .map(d -> Presenter.dashboard(actorLogin, d)); + } + + private Result resolveUser(String login) { + Objects.requireNonNull(login, "login"); + + var cached = state.userId(login); + if (cached.isPresent()) { + return Result.ok(cached.get()); + } + + return users.findByLogin(login) + .map(u -> { + state.rememberUser(login, u.id()); + return Result.ok(u.id()); + }) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("User(login)", login)))); + } + + private Result resolveProjectId(String projectRef) { + Objects.requireNonNull(projectRef, "projectRef"); + var ref = projectRef.trim(); + + if (ref.equalsIgnoreCase("lastProject") || ref.equalsIgnoreCase("last")) { + return state.lastProjectId() + .map(Result::ok) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Project(last)", "no lastProject in state")))); + } + + if (CliState.looksLikeUuid(ref)) { + return Result.ok(new ProjectId(CliState.parseUuidStrict(ref, "projectId"))); + } + + return state.projectIdByKey(ref) + .map(Result::ok) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Project(key)", ref)))); + } + + private Result resolveMilestoneId(ProjectId projectId, String milestoneRef) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(milestoneRef, "milestoneRef"); + var ref = milestoneRef.trim(); + + if (ref.equalsIgnoreCase("lastMilestone") || ref.equalsIgnoreCase("last")) { + return state.lastMilestone(projectId) + .map(Result::ok) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Milestone(last)", "no lastMilestone in state")))); + } + + if (!CliState.looksLikeUuid(ref)) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.InvalidValue("milestoneRef", "expected UUID or lastMilestone/last"))); + } + + return Result.ok(new MilestoneId(CliState.parseUuidStrict(ref, "milestoneId"))); + } + + private Result resolveTicketId(ProjectId projectId, String ticketRef) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(ticketRef, "ticketRef"); + var ref = ticketRef.trim(); + + if (ref.equalsIgnoreCase("lastTicket") || ref.equalsIgnoreCase("last")) { + return state.lastTicket(projectId) + .map(Result::ok) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("Ticket(last)", "no lastTicket in state")))); + } + + if (!CliState.looksLikeUuid(ref)) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.InvalidValue("ticketRef", "expected UUID or lastTicket/last"))); + } + + return Result.ok(new TicketId(CliState.parseUuidStrict(ref, "ticketId"))); + } + + private Result resolveBugId(ProjectId projectId, String bugRef) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(bugRef, "bugRef"); + var ref = bugRef.trim(); + + if (ref.equalsIgnoreCase("lastBug") || ref.equalsIgnoreCase("last")) { + return state.lastBug(projectId) + .map(Result::ok) + .orElseGet(() -> Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.NotFound("BugReport(last)", "no lastBug in state")))); + } + + if (!CliState.looksLikeUuid(ref)) { + return Result.fail(new org.lab.app.FailureCause.Domain(new DomainError.InvalidValue("bugRef", "expected UUID or lastBug/last"))); + } + + return Result.ok(new BugReportId(CliState.parseUuidStrict(ref, "bugReportId"))); + } + + private TicketStatus loadTicketStatus(ProjectId projectId, TicketId ticketId) { + var p = projects.findById(projectId).orElse(null); + if (p == null) return null; + var t = p.tickets().get(ticketId); + if (t == null) return null; + return t.status(); + } + + private BugStatus loadBugStatus(ProjectId projectId, BugReportId bugId) { + var p = projects.findById(projectId).orElse(null); + if (p == null) return null; + var b = p.bugReports().get(bugId); + if (b == null) return null; + return b.status(); + } + + private enum MemberRole { DEVELOPER, TESTER } + + /** + * Modern Java: + * - Switch expression по строке: компактная нормализация входа (DEV/DEVELOPER, TEST/TESTER) через switch ->. + * - Locale.ROOT: корректная нормализация регистра из стандартной библиотеки (без региональных сюрпризов). + */ + private MemberRole normalizeRole(String roleRaw) { + Objects.requireNonNull(roleRaw, "roleRaw"); + var v = roleRaw.trim().toUpperCase(Locale.ROOT); + return switch (v) { + case "DEV", "DEVELOPER" -> MemberRole.DEVELOPER; + case "TEST", "TESTER" -> MemberRole.TESTER; + default -> throw new IllegalArgumentException("Unknown role for AddDev: " + roleRaw + " (expected DEVELOPER/TESTER)"); + }; + } +} diff --git a/src/main/java/org/lab/cli/CliState.java b/src/main/java/org/lab/cli/CliState.java new file mode 100644 index 0000000..f27f6f5 --- /dev/null +++ b/src/main/java/org/lab/cli/CliState.java @@ -0,0 +1,108 @@ +package org.lab.cli; + +import org.lab.domain.*; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class CliState { + + private final ConcurrentHashMap userByLogin = new ConcurrentHashMap<>(); + private final ConcurrentHashMap projectByKey = new ConcurrentHashMap<>(); + + private volatile ProjectId lastProjectId; + private volatile String lastProjectKey; + + private final ConcurrentHashMap lastMilestoneByProject = new ConcurrentHashMap<>(); + private final ConcurrentHashMap lastTicketByProject = new ConcurrentHashMap<>(); + private final ConcurrentHashMap lastBugByProject = new ConcurrentHashMap<>(); + + public void rememberUser(String login, UserId id) { + Objects.requireNonNull(login, "login"); + Objects.requireNonNull(id, "id"); + userByLogin.put(login, id); + } + + public Optional userId(String login) { + Objects.requireNonNull(login, "login"); + return Optional.ofNullable(userByLogin.get(login)); + } + + public void rememberProject(ProjectId id, String key) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(key, "key"); + projectByKey.put(key, id); + lastProjectId = id; + lastProjectKey = key; + } + + public Optional projectIdByKey(String key) { + Objects.requireNonNull(key, "key"); + return Optional.ofNullable(projectByKey.get(key)); + } + + public Optional lastProjectId() { + return Optional.ofNullable(lastProjectId); + } + + public Optional lastProjectKey() { + return Optional.ofNullable(lastProjectKey); + } + + public void rememberMilestone(ProjectId projectId, MilestoneId milestoneId) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(milestoneId, "milestoneId"); + lastMilestoneByProject.put(projectId, milestoneId); + } + + public Optional lastMilestone(ProjectId projectId) { + Objects.requireNonNull(projectId, "projectId"); + return Optional.ofNullable(lastMilestoneByProject.get(projectId)); + } + + public void rememberTicket(ProjectId projectId, TicketId ticketId) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(ticketId, "ticketId"); + lastTicketByProject.put(projectId, ticketId); + } + + public Optional lastTicket(ProjectId projectId) { + Objects.requireNonNull(projectId, "projectId"); + return Optional.ofNullable(lastTicketByProject.get(projectId)); + } + + public void rememberBug(ProjectId projectId, BugReportId bugId) { + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(bugId, "bugId"); + lastBugByProject.put(projectId, bugId); + } + + public Optional lastBug(ProjectId projectId) { + Objects.requireNonNull(projectId, "projectId"); + return Optional.ofNullable(lastBugByProject.get(projectId)); + } + + /** + * Modern Java: + * - Стандартная библиотека UUID: строгая валидация/парсинг идентификаторов для ссылок вида UUID в CLI. + */ + public static boolean looksLikeUuid(String s) { + try { + UUID.fromString(s); + return true; + } catch (Exception e) { + return false; + } + } + + public static UUID parseUuidStrict(String s, String fieldName) { + Objects.requireNonNull(s, fieldName); + try { + return UUID.fromString(s); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid UUID for " + fieldName + ": " + s); + } + } +} diff --git a/src/main/java/org/lab/cli/Command.java b/src/main/java/org/lab/cli/Command.java new file mode 100644 index 0000000..f15c63d --- /dev/null +++ b/src/main/java/org/lab/cli/Command.java @@ -0,0 +1,141 @@ +package org.lab.cli; + +import java.time.LocalDate; +import java.util.Objects; + +public sealed interface Command permits + Command.Register, + Command.CreateProject, + Command.AddDev, + Command.CreateMilestone, + Command.ActivateMilestone, + Command.CreateTicket, + Command.AssignTicket, + Command.StartTicket, + Command.DoneTicket, + Command.CreateBug, + Command.FixBug, + Command.TestBug, + Command.CloseBug, + Command.Dashboard { + + record Register(String login, String displayName) implements Command { + public Register { + Objects.requireNonNull(login, "login"); + Objects.requireNonNull(displayName, "displayName"); + } + } + + record CreateProject(String actorLogin, String name, String description) implements Command { + public CreateProject { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(description, "description"); + } + } + + /** + * Команда названа AddDev по ТЗ, но поддерживает добавление DEVELOPER и TESTER через поле role. + * role: "DEVELOPER" | "TESTER" + */ + record AddDev(String actorLogin, String projectRef, String memberLogin, String role) implements Command { + public AddDev { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(memberLogin, "memberLogin"); + Objects.requireNonNull(role, "role"); + } + } + + record CreateMilestone(String actorLogin, String projectRef, String name, LocalDate start, LocalDate end) implements Command { + public CreateMilestone { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + } + } + + record ActivateMilestone(String actorLogin, String projectRef, String milestoneRef) implements Command { + public ActivateMilestone { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(milestoneRef, "milestoneRef"); + } + } + + record CreateTicket(String actorLogin, String projectRef, String milestoneRef, String title, String description) implements Command { + public CreateTicket { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(milestoneRef, "milestoneRef"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + } + } + + record AssignTicket(String actorLogin, String projectRef, String ticketRef, String developerLogin) implements Command { + public AssignTicket { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(ticketRef, "ticketRef"); + Objects.requireNonNull(developerLogin, "developerLogin"); + } + } + + record StartTicket(String actorLogin, String projectRef, String ticketRef) implements Command { + public StartTicket { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(ticketRef, "ticketRef"); + } + } + + record DoneTicket(String actorLogin, String projectRef, String ticketRef) implements Command { + public DoneTicket { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(ticketRef, "ticketRef"); + } + } + + record CreateBug(String actorLogin, String projectRef, String title, String description) implements Command { + public CreateBug { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + } + } + + record FixBug(String actorLogin, String projectRef, String bugRef) implements Command { + public FixBug { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(bugRef, "bugRef"); + } + } + + record TestBug(String actorLogin, String projectRef, String bugRef) implements Command { + public TestBug { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(bugRef, "bugRef"); + } + } + + record CloseBug(String actorLogin, String projectRef, String bugRef) implements Command { + public CloseBug { + Objects.requireNonNull(actorLogin, "actorLogin"); + Objects.requireNonNull(projectRef, "projectRef"); + Objects.requireNonNull(bugRef, "bugRef"); + } + } + + record Dashboard(String actorLogin) implements Command { + public Dashboard { + Objects.requireNonNull(actorLogin, "actorLogin"); + } + } +} diff --git a/src/main/java/org/lab/cli/CommandParser.java b/src/main/java/org/lab/cli/CommandParser.java new file mode 100644 index 0000000..587f9ef --- /dev/null +++ b/src/main/java/org/lab/cli/CommandParser.java @@ -0,0 +1,218 @@ +package org.lab.cli; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class CommandParser { + + private CommandParser() { + } + + public sealed interface Parsed permits Parsed.Ok, Parsed.Error { + record Ok(Command command) implements Parsed { } + record Error(String message) implements Parsed { } + } + + /** + * Modern Java: + * - Sealed interface + records: Parsed = Ok(Command) | Error(message) как типизированный результат парсинга. + * - Switch expression: диспетчеризация по имени команды через switch -> (без цепочек if/else). + */ + public static Parsed parse(String line) { + if (line == null) { + return new Parsed.Error("Empty input"); + } + var trimmed = line.trim(); + if (trimmed.isEmpty()) { + return new Parsed.Error("Empty input"); + } + + List tokens; + try { + tokens = tokenize(trimmed); + } catch (IllegalArgumentException e) { + return new Parsed.Error(e.getMessage()); + } + + if (tokens.isEmpty()) { + return new Parsed.Error("Empty input"); + } + + var cmd = tokens.get(0).toLowerCase(); + + try { + return switch (cmd) { + case "register" -> parseRegister(tokens); + case "create-project" -> parseCreateProject(tokens); + + case "add-dev" -> parseAddMember(tokens, "DEVELOPER"); + case "add-tester" -> parseAddMember(tokens, "TESTER"); + + case "create-milestone" -> parseCreateMilestone(tokens); + case "activate-milestone" -> parseActivateMilestone(tokens); + + case "create-ticket" -> parseCreateTicket(tokens); + case "assign-ticket" -> parseAssignTicket(tokens); + + case "start-ticket" -> parseStartTicket(tokens); + case "done-ticket" -> parseDoneTicket(tokens); + + case "create-bug" -> parseCreateBug(tokens); + case "fix-bug" -> parseFixBug(tokens); + case "test-bug" -> parseTestBug(tokens); + case "close-bug" -> parseCloseBug(tokens); + + case "dashboard" -> parseDashboard(tokens); + + default -> new Parsed.Error("Unknown command: " + tokens.get(0)); + }; + } catch (IllegalArgumentException e) { + return new Parsed.Error(e.getMessage()); + } + } + + private static Parsed parseRegister(List t) { + requireSize(t, 3, "register \"Display Name\""); + return new Parsed.Ok(new Command.Register(t.get(1), t.get(2))); + } + + private static Parsed parseCreateProject(List t) { + requireSize(t, 4, "create-project \"Project Name\" \"Description\""); + return new Parsed.Ok(new Command.CreateProject(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseAddMember(List t, String role) { + requireSize(t, 4, "add-dev/add-tester "); + return new Parsed.Ok(new Command.AddDev(t.get(1), t.get(2), t.get(3), role)); + } + + private static Parsed parseCreateMilestone(List t) { + requireSize(t, 6, "create-milestone \"Milestone Name\" "); + var start = parseDate(t.get(4), "start"); + var end = parseDate(t.get(5), "end"); + return new Parsed.Ok(new Command.CreateMilestone(t.get(1), t.get(2), t.get(3), start, end)); + } + + private static Parsed parseActivateMilestone(List t) { + requireSize(t, 4, "activate-milestone "); + return new Parsed.Ok(new Command.ActivateMilestone(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseCreateTicket(List t) { + requireSize(t, 6, "create-ticket \"Title\" \"Description\""); + return new Parsed.Ok(new Command.CreateTicket(t.get(1), t.get(2), t.get(3), t.get(4), t.get(5))); + } + + private static Parsed parseAssignTicket(List t) { + requireSize(t, 5, "assign-ticket "); + return new Parsed.Ok(new Command.AssignTicket(t.get(1), t.get(2), t.get(3), t.get(4))); + } + + private static Parsed parseStartTicket(List t) { + requireSize(t, 4, "start-ticket "); + return new Parsed.Ok(new Command.StartTicket(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseDoneTicket(List t) { + requireSize(t, 4, "done-ticket "); + return new Parsed.Ok(new Command.DoneTicket(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseCreateBug(List t) { + requireSize(t, 5, "create-bug \"Title\" \"Description\""); + return new Parsed.Ok(new Command.CreateBug(t.get(1), t.get(2), t.get(3), t.get(4))); + } + + private static Parsed parseFixBug(List t) { + requireSize(t, 4, "fix-bug "); + return new Parsed.Ok(new Command.FixBug(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseTestBug(List t) { + requireSize(t, 4, "test-bug "); + return new Parsed.Ok(new Command.TestBug(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseCloseBug(List t) { + requireSize(t, 4, "close-bug "); + return new Parsed.Ok(new Command.CloseBug(t.get(1), t.get(2), t.get(3))); + } + + private static Parsed parseDashboard(List t) { + requireSize(t, 2, "dashboard "); + return new Parsed.Ok(new Command.Dashboard(t.get(1))); + } + + private static LocalDate parseDate(String raw, String field) { + Objects.requireNonNull(raw, field); + try { + return LocalDate.parse(raw); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid date for " + field + ": " + raw + " (expected yyyy-mm-dd)"); + } + } + + private static void requireSize(List t, int expected, String usage) { + if (t.size() != expected) { + throw new IllegalArgumentException("Invalid arguments. Usage: " + usage); + } + } + + /** + * Modern Java: + * - Расширенная стандартная библиотека: StringBuilder + посимвольный разбор + Character.isWhitespace(). + * - Поддерживает quoted-аргументы "..." и escaping внутри кавычек без внешних зависимостей. + */ + + private static List tokenize(String input) { + Objects.requireNonNull(input, "input"); + var out = new ArrayList(); + + var sb = new StringBuilder(); + boolean inQuotes = false; + boolean escaping = false; + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + + if (escaping) { + sb.append(c); + escaping = false; + continue; + } + + if (inQuotes && c == '\\') { + escaping = true; + continue; + } + + if (c == '"') { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && Character.isWhitespace(c)) { + if (!sb.isEmpty()) { + out.add(sb.toString()); + sb.setLength(0); + } + continue; + } + + sb.append(c); + } + + if (escaping) { + throw new IllegalArgumentException("Invalid escaping in input"); + } + if (inQuotes) { + throw new IllegalArgumentException("Unclosed quotes in input"); + } + if (!sb.isEmpty()) { + out.add(sb.toString()); + } + return out; + } +} diff --git a/src/main/java/org/lab/domain/BugReport.java b/src/main/java/org/lab/domain/BugReport.java new file mode 100644 index 0000000..37bb1b7 --- /dev/null +++ b/src/main/java/org/lab/domain/BugReport.java @@ -0,0 +1,151 @@ +package org.lab.domain; + +import org.lab.domain.enums.BugStatus; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +public record BugReport( + BugReportId id, + ProjectId projectId, + Title title, + Description description, + BugStatus status, + UserId createdBy, + UserId assignedTo, + UserId fixedBy, + UserId testedBy, + Instant createdAt, + Instant updatedAt +) { + + public BugReport { } + + public static DomainResult create(BugReportId id, + ProjectId projectId, + Title title, + Description description, + UserId createdBy, + Instant now) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(createdBy, "createdBy"); + return Validation.nonNullInstant("now", now) + .map(ts -> new BugReport( + id, + projectId, + title, + description, + BugStatus.NEW, + createdBy, + null, + null, + null, + ts, + ts + )); + } + + public BugReport assignTo(UserId developer, Instant now) { + Objects.requireNonNull(developer, "developer"); + Objects.requireNonNull(now, "now"); + return new BugReport(id, projectId, title, description, status, createdBy, developer, fixedBy, testedBy, createdAt, now); + } + + public Optional assignedToOpt() { + return Optional.ofNullable(assignedTo); + } + /** + * Modern Java: + * - Pattern matching for switch: switch по sealed FailureCause с record-pattern’ами + * (case FailureCause.Domain(var error) -> ..., case AccessDenied denied -> ...). + * - Text Blocks ("""...""") + formatted(): человекочитаемые блоки ошибок домена и ошибок доступа. + */ + public DomainResult apply(BugReportAction action, Instant now) { + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(now, "now"); + + return switch (action) { + case BugReportAction.Fix(var actor) -> fix(actor, now); + case BugReportAction.Test(var actor) -> test(actor, now); + case BugReportAction.Close(var actor) -> close(actor, now); + }; + } + + private DomainResult fix(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != BugStatus.NEW) { + return DomainResult.err(new DomainError.InvalidTransition( + "BugReport", + status.name(), + BugStatus.FIXED.name(), + "fix allowed only from NEW" + )); + } + // Если assignedTo не задан — допускаем "взял и исправил", но фиксируем назначение. + var effectiveAssigned = assignedTo != null ? assignedTo : actor; + if (!effectiveAssigned.equals(actor)) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.assignee", + "only assigned developer can fix the bug" + )); + } + return DomainResult.ok(new BugReport( + id, projectId, title, description, + BugStatus.FIXED, + createdBy, + effectiveAssigned, + actor, + testedBy, + createdAt, + now + )); + } + + private DomainResult test(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != BugStatus.FIXED) { + return DomainResult.err(new DomainError.InvalidTransition( + "BugReport", + status.name(), + BugStatus.TESTED.name(), + "test allowed only from FIXED" + )); + } + return DomainResult.ok(new BugReport( + id, projectId, title, description, + BugStatus.TESTED, + createdBy, + assignedTo, + fixedBy, + actor, + createdAt, + now + )); + } + + private DomainResult close(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != BugStatus.TESTED) { + return DomainResult.err(new DomainError.InvalidTransition( + "BugReport", + status.name(), + BugStatus.CLOSED.name(), + "close allowed only from TESTED" + )); + } + return DomainResult.ok(new BugReport( + id, projectId, title, description, + BugStatus.CLOSED, + createdBy, + assignedTo, + fixedBy, + testedBy, + createdAt, + now + )); + } +} diff --git a/src/main/java/org/lab/domain/BugReportAction.java b/src/main/java/org/lab/domain/BugReportAction.java new file mode 100644 index 0000000..824b83b --- /dev/null +++ b/src/main/java/org/lab/domain/BugReportAction.java @@ -0,0 +1,10 @@ +package org.lab.domain; + +public sealed interface BugReportAction permits BugReportAction.Fix, BugReportAction.Test, BugReportAction.Close { + + record Fix(UserId actor) implements BugReportAction { } + + record Test(UserId actor) implements BugReportAction { } + + record Close(UserId actor) implements BugReportAction { } +} diff --git a/src/main/java/org/lab/domain/BugReportId.java b/src/main/java/org/lab/domain/BugReportId.java new file mode 100644 index 0000000..aa1123d --- /dev/null +++ b/src/main/java/org/lab/domain/BugReportId.java @@ -0,0 +1,19 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.UUID; + +public record BugReportId(UUID value) { + public BugReportId { + Objects.requireNonNull(value, "value"); + } + + public static BugReportId newId() { + return new BugReportId(UUID.randomUUID()); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/org/lab/domain/DateRange.java b/src/main/java/org/lab/domain/DateRange.java new file mode 100644 index 0000000..31fc309 --- /dev/null +++ b/src/main/java/org/lab/domain/DateRange.java @@ -0,0 +1,23 @@ +package org.lab.domain; + +import java.time.LocalDate; +import java.util.Objects; + +public record DateRange(LocalDate start, LocalDate end) { + + public DateRange { + // constructor kept private via factories + } + + public static DomainResult of(LocalDate start, LocalDate end) { + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(end, "end"); + if (start.isAfter(end)) { + return DomainResult.err(new DomainError.InvalidValue( + "dateRange", + "start must be <= end" + )); + } + return DomainResult.ok(new DateRange(start, end)); + } +} diff --git a/src/main/java/org/lab/domain/Description.java b/src/main/java/org/lab/domain/Description.java new file mode 100644 index 0000000..4926338 --- /dev/null +++ b/src/main/java/org/lab/domain/Description.java @@ -0,0 +1,18 @@ +package org.lab.domain; + +public record Description(String value) { + + private static final int MAX = 4000; + + public Description { + // constructor kept private via factories + } + + public static DomainResult of(String raw) { + if (raw == null) { + return DomainResult.ok(new Description("")); + } + var v = raw.trim(); + return Validation.maxLen("description", v, MAX).map(Description::new); + } +} diff --git a/src/main/java/org/lab/domain/DomainError.java b/src/main/java/org/lab/domain/DomainError.java new file mode 100644 index 0000000..df22e42 --- /dev/null +++ b/src/main/java/org/lab/domain/DomainError.java @@ -0,0 +1,101 @@ +package org.lab.domain; + +import java.util.Objects; + +public sealed interface DomainError + permits DomainError.InvalidValue, + DomainError.NotFound, + DomainError.Conflict, + DomainError.InvariantViolation, + DomainError.InvalidTransition { + + String code(); + + String userMessage(); + + record InvalidValue(String field, String message) implements DomainError { + public InvalidValue { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(message, "message"); + } + + @Override + public String code() { + return "INVALID_VALUE"; + } + + @Override + public String userMessage() { + return "Invalid value for '" + field + "': " + message; + } + } + + record NotFound(String entity, String id) implements DomainError { + public NotFound { + Objects.requireNonNull(entity, "entity"); + Objects.requireNonNull(id, "id"); + } + + @Override + public String code() { + return "NOT_FOUND"; + } + + @Override + public String userMessage() { + return entity + " not found: " + id; + } + } + + record Conflict(String message) implements DomainError { + public Conflict { + Objects.requireNonNull(message, "message"); + } + + @Override + public String code() { + return "CONFLICT"; + } + + @Override + public String userMessage() { + return message; + } + } + + record InvariantViolation(String invariant, String message) implements DomainError { + public InvariantViolation { + Objects.requireNonNull(invariant, "invariant"); + Objects.requireNonNull(message, "message"); + } + + @Override + public String code() { + return "INVARIANT_VIOLATION"; + } + + @Override + public String userMessage() { + return invariant + ": " + message; + } + } + + record InvalidTransition(String entity, String from, String to, String message) implements DomainError { + public InvalidTransition { + Objects.requireNonNull(entity, "entity"); + Objects.requireNonNull(from, "from"); + Objects.requireNonNull(to, "to"); + Objects.requireNonNull(message, "message"); + } + + @Override + public String code() { + return "INVALID_TRANSITION"; + } + + @Override + public String userMessage() { + return entity + " transition " + from + " -> " + to + " is invalid: " + message; + } + } +} diff --git a/src/main/java/org/lab/domain/DomainResult.java b/src/main/java/org/lab/domain/DomainResult.java new file mode 100644 index 0000000..6e3566d --- /dev/null +++ b/src/main/java/org/lab/domain/DomainResult.java @@ -0,0 +1,108 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +public sealed interface DomainResult permits DomainResult.Success, DomainResult.Failure { + + boolean isSuccess(); + + default boolean isFailure() { + return !isSuccess(); + } + + Optional toOptional(); + + T orElseThrow(); + + DomainError errorOrNull(); + + DomainResult map(Function mapper); + + DomainResult flatMap(Function> mapper); + + static DomainResult ok(T value) { + return new Success<>(value); + } + + static DomainResult err(DomainError error) { + return new Failure<>(Objects.requireNonNull(error, "error")); + } + + record Success(T value) implements DomainResult { + public Success { + Objects.requireNonNull(value, "value"); + } + + @Override + public boolean isSuccess() { + return true; + } + + @Override + public Optional toOptional() { + return Optional.of(value); + } + + @Override + public T orElseThrow() { + return value; + } + + @Override + public DomainError errorOrNull() { + return null; + } + + @Override + public DomainResult map(Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return DomainResult.ok(mapper.apply(value)); + } + + @Override + public DomainResult flatMap(Function> mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Objects.requireNonNull(mapper.apply(value), "mapper result"); + } + } + + record Failure(DomainError error) implements DomainResult { + public Failure { + Objects.requireNonNull(error, "error"); + } + + @Override + public boolean isSuccess() { + return false; + } + + @Override + public Optional toOptional() { + return Optional.empty(); + } + + @Override + public T orElseThrow() { + throw new IllegalStateException("DomainResult is failure: " + error.userMessage()); + } + + @Override + public DomainError errorOrNull() { + return error; + } + + @Override + public DomainResult map(Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return DomainResult.err(error); + } + + @Override + public DomainResult flatMap(Function> mapper) { + Objects.requireNonNull(mapper, "mapper"); + return DomainResult.err(error); + } + } +} diff --git a/src/main/java/org/lab/domain/Milestone.java b/src/main/java/org/lab/domain/Milestone.java new file mode 100644 index 0000000..30a5c43 --- /dev/null +++ b/src/main/java/org/lab/domain/Milestone.java @@ -0,0 +1,41 @@ +package org.lab.domain; + +import org.lab.domain.enums.MilestoneStatus; + +import java.time.Instant; +import java.util.Objects; + +public record Milestone( + MilestoneId id, + ProjectId projectId, + String name, + DateRange range, + MilestoneStatus status, + Instant createdAt, + Instant updatedAt +) { + + public Milestone { } + + public static DomainResult create(MilestoneId id, + ProjectId projectId, + String name, + DateRange range, + Instant now) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(range, "range"); + return Validation.nonBlank("milestoneName", name) + .flatMap(n -> Validation.maxLen("milestoneName", n, 200)) + .flatMap(n -> + Validation.nonNullInstant("now", now) + .map(ts -> new Milestone(id, projectId, n, range, MilestoneStatus.OPEN, ts, ts)) + ); + } + + Milestone withStatus(MilestoneStatus next, Instant now) { + Objects.requireNonNull(next, "next"); + Objects.requireNonNull(now, "now"); + return new Milestone(id, projectId, name, range, next, createdAt, now); + } +} diff --git a/src/main/java/org/lab/domain/MilestoneId.java b/src/main/java/org/lab/domain/MilestoneId.java new file mode 100644 index 0000000..b4aea17 --- /dev/null +++ b/src/main/java/org/lab/domain/MilestoneId.java @@ -0,0 +1,19 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.UUID; + +public record MilestoneId(UUID value) { + public MilestoneId { + Objects.requireNonNull(value, "value"); + } + + public static MilestoneId newId() { + return new MilestoneId(UUID.randomUUID()); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/org/lab/domain/Project.java b/src/main/java/org/lab/domain/Project.java new file mode 100644 index 0000000..204a6b5 --- /dev/null +++ b/src/main/java/org/lab/domain/Project.java @@ -0,0 +1,561 @@ +package org.lab.domain; + +import org.lab.domain.enums.BugStatus; +import org.lab.domain.enums.MilestoneStatus; +import org.lab.domain.enums.ProjectRole; +import org.lab.domain.enums.TicketStatus; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public record Project( + ProjectId id, + ProjectKey key, + String name, + Description description, + UserId managerId, + UserId teamLeadId, + Map members, + Map milestones, + Map tickets, + Map bugReports, + Instant createdAt, + Instant updatedAt +) { + + public Project { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(managerId, "managerId"); + Objects.requireNonNull(members, "members"); + Objects.requireNonNull(milestones, "milestones"); + Objects.requireNonNull(tickets, "tickets"); + Objects.requireNonNull(bugReports, "bugReports"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(updatedAt, "updatedAt"); + + members = Map.copyOf(members); + milestones = Map.copyOf(milestones); + tickets = Map.copyOf(tickets); + bugReports = Map.copyOf(bugReports); + + var mgrRole = members.get(managerId); + if (mgrRole != ProjectRole.MANAGER) { + throw new IllegalStateException("Project invariant broken: managerId must have MANAGER role"); + } + + if (teamLeadId != null) { + var tlRole = members.get(teamLeadId); + if (tlRole != ProjectRole.TEAM_LEAD) { + throw new IllegalStateException("Project invariant broken: teamLeadId must have TEAM_LEAD role"); + } + } + + long activeCount = milestones.values().stream().filter(m -> m.status() == MilestoneStatus.ACTIVE).count(); + if (activeCount > 1) { + throw new IllegalStateException("Project invariant broken: only one ACTIVE milestone allowed"); + } + } + + public static DomainResult create(ProjectId id, + String rawKey, + String rawName, + String rawDescription, + UserId managerId, + Instant now) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(managerId, "managerId"); + + return ProjectKey.of(rawKey) + .flatMap(k -> Validation.nonBlank("projectName", rawName) + .flatMap(n -> Validation.maxLen("projectName", n, 200)) + .flatMap(n -> { + var desc = rawDescription == null ? "" : rawDescription.trim(); + if (desc.length() > 4000) { + return DomainResult.err(new DomainError.InvalidValue("projectDescription", "length must be <= 4000")); + } + return Validation.nonNullInstant("now", now).map(ts -> { + var members = new HashMap(); + members.put(managerId, ProjectRole.MANAGER); + return new Project( + id, k, n, new Description(desc), + managerId, null, + members, + Map.of(), + Map.of(), + Map.of(), + ts, ts + ); + }); + }) + ); + } + + public Optional teamLeadIdOpt() { + return Optional.ofNullable(teamLeadId); + } + + public Optional roleOf(UserId userId) { + return Optional.ofNullable(members.get(userId)); + } + + public boolean isMember(UserId userId) { + return members.containsKey(userId); + } + + public DomainResult addDeveloper(UserId userId, Instant now) { + return addMember(userId, ProjectRole.DEVELOPER, now); + } + + public DomainResult addTester(UserId userId, Instant now) { + return addMember(userId, ProjectRole.TESTER, now); + } + + public DomainResult assignTeamLead(UserId userId, Instant now) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(now, "now"); + + var current = members.get(userId); + if (current == ProjectRole.TESTER) { + return DomainResult.err(new DomainError.Conflict("Cannot promote TESTER to TEAM_LEAD in this simplified model")); + } + + var nextMembers = new HashMap<>(members); + nextMembers.put(userId, ProjectRole.TEAM_LEAD); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, userId, + nextMembers, + milestones, + tickets, + bugReports, + createdAt, + now + )); + } + + private DomainResult addMember(UserId userId, ProjectRole role, Instant now) { + Objects.requireNonNull(userId, "userId"); + Objects.requireNonNull(role, "role"); + Objects.requireNonNull(now, "now"); + + var existing = members.get(userId); + if (existing != null) { + if (existing == role) { + return DomainResult.ok(this); // идемпотентность + } + return DomainResult.err(new DomainError.Conflict( + "User already has role " + existing + " in project; simplified model allows only one role" + )); + } + + var next = new HashMap<>(members); + next.put(userId, role); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, teamLeadId, + next, + milestones, + tickets, + bugReports, + createdAt, + now + )); + } + + // ---------- Milestones ---------- + + public DomainResult createMilestone(MilestoneId milestoneId, + String milestoneName, + DateRange range, + Instant now) { + Objects.requireNonNull(milestoneId, "milestoneId"); + Objects.requireNonNull(range, "range"); + Objects.requireNonNull(now, "now"); + + if (milestones.containsKey(milestoneId)) { + return DomainResult.err(new DomainError.Conflict("Milestone already exists: " + milestoneId)); + } + + return Milestone.create(milestoneId, id, milestoneName, range, now) + .map(ms -> { + var next = new HashMap<>(milestones); + next.put(milestoneId, ms); + return new Project( + id, key, name, description, + managerId, teamLeadId, + members, + next, + tickets, + bugReports, + createdAt, + now + ); + }); + } + + public DomainResult activateMilestone(MilestoneId milestoneId, Instant now) { + Objects.requireNonNull(milestoneId, "milestoneId"); + Objects.requireNonNull(now, "now"); + + var ms = milestones.get(milestoneId); + if (ms == null) { + return DomainResult.err(new DomainError.NotFound("Milestone", milestoneId.toString())); + } + if (ms.status() == MilestoneStatus.CLOSED) { + return DomainResult.err(new DomainError.InvalidTransition( + "Milestone", + MilestoneStatus.CLOSED.name(), + MilestoneStatus.ACTIVE.name(), + "cannot activate closed milestone" + )); + } + if (ms.status() == MilestoneStatus.ACTIVE) { + return DomainResult.ok(this); // идемпотентность + } + + boolean hasAnotherActive = milestones.values().stream() + .anyMatch(m -> m.status() == MilestoneStatus.ACTIVE && !m.id().equals(milestoneId)); + if (hasAnotherActive) { + return DomainResult.err(new DomainError.InvariantViolation( + "project.singleActiveMilestone", + "another ACTIVE milestone already exists" + )); + } + + var next = new HashMap<>(milestones); + next.put(milestoneId, ms.withStatus(MilestoneStatus.ACTIVE, now)); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, teamLeadId, + members, + next, + tickets, + bugReports, + createdAt, + now + )); + } + + public DomainResult closeMilestone(MilestoneId milestoneId, Instant now) { + Objects.requireNonNull(milestoneId, "milestoneId"); + Objects.requireNonNull(now, "now"); + + var ms = milestones.get(milestoneId); + if (ms == null) { + return DomainResult.err(new DomainError.NotFound("Milestone", milestoneId.toString())); + } + if (ms.status() == MilestoneStatus.CLOSED) { + return DomainResult.ok(this); // идемпотентность + } + if (ms.status() != MilestoneStatus.ACTIVE) { + return DomainResult.err(new DomainError.InvalidTransition( + "Milestone", + ms.status().name(), + MilestoneStatus.CLOSED.name(), + "can close only ACTIVE milestone" + )); + } + + boolean allDone = tickets.values().stream() + .filter(t -> t.milestoneId().equals(milestoneId)) + .allMatch(Ticket::isDone); + + if (!allDone) { + return DomainResult.err(new DomainError.InvariantViolation( + "milestone.closeRequiresAllTicketsDone", + "cannot close milestone while it has not DONE tickets" + )); + } + + var next = new HashMap<>(milestones); + next.put(milestoneId, ms.withStatus(MilestoneStatus.CLOSED, now)); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, teamLeadId, + members, + next, + tickets, + bugReports, + createdAt, + now + )); + } + + // ---------- Tickets ---------- + + public DomainResult createTicket(TicketId ticketId, + MilestoneId milestoneId, + Title title, + Description description, + UserId createdBy, + Instant now) { + Objects.requireNonNull(ticketId, "ticketId"); + Objects.requireNonNull(milestoneId, "milestoneId"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(createdBy, "createdBy"); + Objects.requireNonNull(now, "now"); + + if (tickets.containsKey(ticketId)) { + return DomainResult.err(new DomainError.Conflict("Ticket already exists: " + ticketId)); + } + + var ms = milestones.get(milestoneId); + if (ms == null) { + return DomainResult.err(new DomainError.NotFound("Milestone", milestoneId.toString())); + } + if (ms.status() == MilestoneStatus.CLOSED) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.milestoneNotClosed", + "cannot create ticket in CLOSED milestone" + )); + } + + if (!isMember(createdBy)) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.creatorMustBeMember", + "creator must be a project member" + )); + } + + return Ticket.create(ticketId, id, milestoneId, title, description, createdBy, now) + .map(t -> { + var next = new HashMap<>(tickets); + next.put(ticketId, t); + return new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + next, + bugReports, + createdAt, + now + ); + }); + } + + public DomainResult assignDeveloperToTicket(TicketId ticketId, UserId developerId, Instant now) { + Objects.requireNonNull(ticketId, "ticketId"); + Objects.requireNonNull(developerId, "developerId"); + Objects.requireNonNull(now, "now"); + + var t = tickets.get(ticketId); + if (t == null) { + return DomainResult.err(new DomainError.NotFound("Ticket", ticketId.toString())); + } + if (t.status() == TicketStatus.DONE) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.notDoneForAssign", + "cannot assign developers to DONE ticket" + )); + } + + var role = members.get(developerId); + if (role != ProjectRole.DEVELOPER && role != ProjectRole.TEAM_LEAD) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.assigneeRole", + "assignee must be DEVELOPER or TEAM_LEAD" + )); + } + + var updated = t.assign(developerId, now); + var next = new HashMap<>(tickets); + next.put(ticketId, updated); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + next, + bugReports, + createdAt, + now + )); + } + + public DomainResult applyTicketAction(TicketId ticketId, TicketAction action, Instant now) { + Objects.requireNonNull(ticketId, "ticketId"); + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(now, "now"); + + var t = tickets.get(ticketId); + if (t == null) { + return DomainResult.err(new DomainError.NotFound("Ticket", ticketId.toString())); + } + + return t.apply(action, now).map(updated -> { + var next = new HashMap<>(tickets); + next.put(ticketId, updated); + return new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + next, + bugReports, + createdAt, + now + ); + }); + } + + // ---------- BugReports ---------- + + public DomainResult createBugReport(BugReportId bugId, + Title title, + Description description, + UserId createdBy, + Instant now) { + Objects.requireNonNull(bugId, "bugId"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(createdBy, "createdBy"); + Objects.requireNonNull(now, "now"); + + if (bugReports.containsKey(bugId)) { + return DomainResult.err(new DomainError.Conflict("BugReport already exists: " + bugId)); + } + + var role = members.get(createdBy); + if (role != ProjectRole.DEVELOPER && role != ProjectRole.TESTER && role != ProjectRole.TEAM_LEAD) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.creatorRole", + "only DEVELOPER/TEAM_LEAD/TESTER can create bug reports" + )); + } + + return BugReport.create(bugId, id, title, description, createdBy, now) + .map(b -> { + var next = new HashMap<>(bugReports); + next.put(bugId, b); + return new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + tickets, + next, + createdAt, + now + ); + }); + } + + public DomainResult assignBugToDeveloper(BugReportId bugId, UserId developerId, Instant now) { + Objects.requireNonNull(bugId, "bugId"); + Objects.requireNonNull(developerId, "developerId"); + Objects.requireNonNull(now, "now"); + + var b = bugReports.get(bugId); + if (b == null) { + return DomainResult.err(new DomainError.NotFound("BugReport", bugId.toString())); + } + if (b.status() == BugStatus.CLOSED) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.notClosedForAssign", + "cannot assign developer to CLOSED bug report" + )); + } + + var role = members.get(developerId); + if (role != ProjectRole.DEVELOPER && role != ProjectRole.TEAM_LEAD) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.assigneeRole", + "assignee must be DEVELOPER or TEAM_LEAD" + )); + } + + var updated = b.assignTo(developerId, now); + var next = new HashMap<>(bugReports); + next.put(bugId, updated); + + return DomainResult.ok(new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + tickets, + next, + createdAt, + now + )); + } + + public DomainResult applyBugReportAction(BugReportId bugId, BugReportAction action, Instant now) { + Objects.requireNonNull(bugId, "bugId"); + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(now, "now"); + + var b = bugReports.get(bugId); + if (b == null) { + return DomainResult.err(new DomainError.NotFound("BugReport", bugId.toString())); + } + + // доменные ограничения на роли (чтобы модель не могла стать "неконсистентной") + var actor = switch (action) { + case BugReportAction.Fix(var a) -> a; + case BugReportAction.Test(var a) -> a; + case BugReportAction.Close(var a) -> a; + }; + + var role = members.get(actor); + if (role == null) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.actorIsMember", + "actor must be a project member" + )); + } + + if (action instanceof BugReportAction.Fix) { + if (role != ProjectRole.DEVELOPER && role != ProjectRole.TEAM_LEAD) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.fixRole", + "only DEVELOPER/TEAM_LEAD can fix bugs" + )); + } + } + if (action instanceof BugReportAction.Test) { + if (role != ProjectRole.TESTER) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.testRole", + "only TESTER can test bug fixes" + )); + } + } + if (action instanceof BugReportAction.Close) { + if (role != ProjectRole.MANAGER && role != ProjectRole.TESTER) { + return DomainResult.err(new DomainError.InvariantViolation( + "bug.closeRole", + "only MANAGER or TESTER can close bugs" + )); + } + } + + return b.apply(action, now).map(updated -> { + var next = new HashMap<>(bugReports); + next.put(bugId, updated); + return new Project( + id, key, name, description, + managerId, teamLeadId, + members, + milestones, + tickets, + next, + createdAt, + now + ); + }); + } +} diff --git a/src/main/java/org/lab/domain/ProjectId.java b/src/main/java/org/lab/domain/ProjectId.java new file mode 100644 index 0000000..c63a94b --- /dev/null +++ b/src/main/java/org/lab/domain/ProjectId.java @@ -0,0 +1,19 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.UUID; + +public record ProjectId(UUID value) { + public ProjectId { + Objects.requireNonNull(value, "value"); + } + + public static ProjectId newId() { + return new ProjectId(UUID.randomUUID()); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/org/lab/domain/ProjectKey.java b/src/main/java/org/lab/domain/ProjectKey.java new file mode 100644 index 0000000..283c356 --- /dev/null +++ b/src/main/java/org/lab/domain/ProjectKey.java @@ -0,0 +1,14 @@ +package org.lab.domain; + +public record ProjectKey(String value) { + + private static final int MAX = 32; + + public ProjectKey { } + + public static DomainResult of(String raw) { + return Validation.nonBlank("projectKey", raw) + .flatMap(v -> Validation.maxLen("projectKey", v, MAX)) + .map(ProjectKey::new); + } +} diff --git a/src/main/java/org/lab/domain/Ticket.java b/src/main/java/org/lab/domain/Ticket.java new file mode 100644 index 0000000..2fd907f --- /dev/null +++ b/src/main/java/org/lab/domain/Ticket.java @@ -0,0 +1,140 @@ +package org.lab.domain; + +import org.lab.domain.enums.TicketStatus; + +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +public record Ticket( + TicketId id, + ProjectId projectId, + MilestoneId milestoneId, + Title title, + Description description, + TicketStatus status, + Set assignees, + UserId createdBy, + Instant createdAt, + Instant updatedAt +) { + + public Ticket { } + + public static DomainResult create(TicketId id, + ProjectId projectId, + MilestoneId milestoneId, + Title title, + Description description, + UserId createdBy, + Instant now) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(projectId, "projectId"); + Objects.requireNonNull(milestoneId, "milestoneId"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(createdBy, "createdBy"); + return Validation.nonNullInstant("now", now) + .map(ts -> new Ticket( + id, + projectId, + milestoneId, + title, + description, + TicketStatus.NEW, + Set.of(), + createdBy, + ts, + ts + )); + } + + public Ticket assign(UserId developer, Instant now) { + Objects.requireNonNull(developer, "developer"); + Objects.requireNonNull(now, "now"); + var next = new LinkedHashSet<>(assignees); + next.add(developer); + return new Ticket(id, projectId, milestoneId, title, description, status, Set.copyOf(next), createdBy, createdAt, now); + } + + public boolean isDone() { + return status == TicketStatus.DONE; + } + + /** + * Pattern matching for switch (демонстрация modern Java). + * Переходы статусов делаем чисто доменно (RBAC будет в application, но домен держит корректные переходы). + */ + public DomainResult apply(TicketAction action, Instant now) { + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(now, "now"); + + return switch (action) { + case TicketAction.Accept(var actor) -> accept(actor, now); + case TicketAction.Start(var actor) -> start(actor, now); + case TicketAction.Complete(var actor) -> complete(actor, now); + }; + } + + private DomainResult accept(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != TicketStatus.NEW) { + return DomainResult.err(new DomainError.InvalidTransition( + "Ticket", + status.name(), + TicketStatus.ACCEPTED.name(), + "accept allowed only from NEW" + )); + } + if (!assignees.contains(actor)) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.assignee", + "actor must be assigned to accept the ticket" + )); + } + return DomainResult.ok(withStatus(TicketStatus.ACCEPTED, now)); + } + + private DomainResult start(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != TicketStatus.ACCEPTED) { + return DomainResult.err(new DomainError.InvalidTransition( + "Ticket", + status.name(), + TicketStatus.IN_PROGRESS.name(), + "start allowed only from ACCEPTED" + )); + } + if (!assignees.contains(actor)) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.assignee", + "actor must be assigned to start the ticket" + )); + } + return DomainResult.ok(withStatus(TicketStatus.IN_PROGRESS, now)); + } + + private DomainResult complete(UserId actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + if (status != TicketStatus.IN_PROGRESS) { + return DomainResult.err(new DomainError.InvalidTransition( + "Ticket", + status.name(), + TicketStatus.DONE.name(), + "complete allowed only from IN_PROGRESS" + )); + } + if (!assignees.contains(actor)) { + return DomainResult.err(new DomainError.InvariantViolation( + "ticket.assignee", + "actor must be assigned to complete the ticket" + )); + } + return DomainResult.ok(withStatus(TicketStatus.DONE, now)); + } + + private Ticket withStatus(TicketStatus next, Instant now) { + return new Ticket(id, projectId, milestoneId, title, description, next, assignees, createdBy, createdAt, now); + } +} diff --git a/src/main/java/org/lab/domain/TicketAction.java b/src/main/java/org/lab/domain/TicketAction.java new file mode 100644 index 0000000..be39800 --- /dev/null +++ b/src/main/java/org/lab/domain/TicketAction.java @@ -0,0 +1,10 @@ +package org.lab.domain; + +public sealed interface TicketAction permits TicketAction.Accept, TicketAction.Start, TicketAction.Complete { + + record Accept(UserId actor) implements TicketAction { } + + record Start(UserId actor) implements TicketAction { } + + record Complete(UserId actor) implements TicketAction { } +} diff --git a/src/main/java/org/lab/domain/TicketId.java b/src/main/java/org/lab/domain/TicketId.java new file mode 100644 index 0000000..170892d --- /dev/null +++ b/src/main/java/org/lab/domain/TicketId.java @@ -0,0 +1,19 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.UUID; + +public record TicketId(UUID value) { + public TicketId { + Objects.requireNonNull(value, "value"); + } + + public static TicketId newId() { + return new TicketId(UUID.randomUUID()); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/org/lab/domain/Title.java b/src/main/java/org/lab/domain/Title.java new file mode 100644 index 0000000..3c95850 --- /dev/null +++ b/src/main/java/org/lab/domain/Title.java @@ -0,0 +1,16 @@ +package org.lab.domain; + +public record Title(String value) { + + private static final int MAX = 200; + + public Title { + // constructor kept private via factories + } + + public static DomainResult of(String raw) { + return Validation.nonBlank("title", raw) + .flatMap(v -> Validation.maxLen("title", v, MAX)) + .map(Title::new); + } +} diff --git a/src/main/java/org/lab/domain/User.java b/src/main/java/org/lab/domain/User.java new file mode 100644 index 0000000..2159838 --- /dev/null +++ b/src/main/java/org/lab/domain/User.java @@ -0,0 +1,28 @@ +package org.lab.domain; + +import java.time.Instant; +import java.util.Objects; + +public record User( + UserId id, + String login, + String displayName, + Instant registeredAt +) { + + public User { } + + public static DomainResult<User> register(UserId id, String login, String displayName, Instant now) { + Objects.requireNonNull(id, "id"); + return Validation.nonBlank("login", login) + .flatMap(l -> Validation.maxLen("login", l, 64)) + .flatMap(l -> + Validation.nonBlank("displayName", displayName) + .flatMap(n -> Validation.maxLen("displayName", n, 128)) + .flatMap(nm -> + Validation.nonNullInstant("registeredAt", now) + .map(ts -> new User(id, l, nm, ts)) + ) + ); + } +} diff --git a/src/main/java/org/lab/domain/UserId.java b/src/main/java/org/lab/domain/UserId.java new file mode 100644 index 0000000..1c3d0de --- /dev/null +++ b/src/main/java/org/lab/domain/UserId.java @@ -0,0 +1,19 @@ +package org.lab.domain; + +import java.util.Objects; +import java.util.UUID; + +public record UserId(UUID value) { + public UserId { + Objects.requireNonNull(value, "value"); + } + + public static UserId newId() { + return new UserId(UUID.randomUUID()); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/src/main/java/org/lab/domain/Validation.java b/src/main/java/org/lab/domain/Validation.java new file mode 100644 index 0000000..d20e8c6 --- /dev/null +++ b/src/main/java/org/lab/domain/Validation.java @@ -0,0 +1,43 @@ +package org.lab.domain; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Objects; + +final class Validation { + + private Validation() { } + + static DomainResult<String> nonBlank(String field, String value) { + if (value == null) { + return DomainResult.err(new DomainError.InvalidValue(field, "must not be null")); + } + var v = value.trim(); + if (v.isEmpty()) { + return DomainResult.err(new DomainError.InvalidValue(field, "must not be blank")); + } + return DomainResult.ok(v); + } + + static DomainResult<String> maxLen(String field, String value, int max) { + Objects.requireNonNull(value, field); + if (value.length() > max) { + return DomainResult.err(new DomainError.InvalidValue(field, "length must be <= " + max)); + } + return DomainResult.ok(value); + } + + static DomainResult<Instant> nonNullInstant(String field, Instant value) { + if (value == null) { + return DomainResult.err(new DomainError.InvalidValue(field, "must not be null")); + } + return DomainResult.ok(value); + } + + static DomainResult<LocalDate> nonNullDate(String field, LocalDate value) { + if (value == null) { + return DomainResult.err(new DomainError.InvalidValue(field, "must not be null")); + } + return DomainResult.ok(value); + } +} diff --git a/src/main/java/org/lab/domain/enums/BugStatus.java b/src/main/java/org/lab/domain/enums/BugStatus.java new file mode 100644 index 0000000..5697e6a --- /dev/null +++ b/src/main/java/org/lab/domain/enums/BugStatus.java @@ -0,0 +1,8 @@ +package org.lab.domain.enums; + +public enum BugStatus { + NEW, + FIXED, + TESTED, + CLOSED +} \ No newline at end of file diff --git a/src/main/java/org/lab/domain/enums/MilestoneStatus.java b/src/main/java/org/lab/domain/enums/MilestoneStatus.java new file mode 100644 index 0000000..2197d76 --- /dev/null +++ b/src/main/java/org/lab/domain/enums/MilestoneStatus.java @@ -0,0 +1,7 @@ +package org.lab.domain.enums; + +public enum MilestoneStatus { + OPEN, + ACTIVE, + CLOSED +} \ No newline at end of file diff --git a/src/main/java/org/lab/domain/enums/ProjectRole.java b/src/main/java/org/lab/domain/enums/ProjectRole.java new file mode 100644 index 0000000..d8c8ef1 --- /dev/null +++ b/src/main/java/org/lab/domain/enums/ProjectRole.java @@ -0,0 +1,8 @@ +package org.lab.domain.enums; + +public enum ProjectRole { + MANAGER, + TEAM_LEAD, + DEVELOPER, + TESTER +} diff --git a/src/main/java/org/lab/domain/enums/TicketStatus.java b/src/main/java/org/lab/domain/enums/TicketStatus.java new file mode 100644 index 0000000..3534f6f --- /dev/null +++ b/src/main/java/org/lab/domain/enums/TicketStatus.java @@ -0,0 +1,8 @@ +package org.lab.domain.enums; + +public enum TicketStatus { + NEW, + ACCEPTED, + IN_PROGRESS, + DONE +} \ No newline at end of file diff --git a/src/main/java/org/lab/infra/BugReportRepository.java b/src/main/java/org/lab/infra/BugReportRepository.java new file mode 100644 index 0000000..cf8afe2 --- /dev/null +++ b/src/main/java/org/lab/infra/BugReportRepository.java @@ -0,0 +1,128 @@ +package org.lab.infra; + +import org.lab.domain.BugReport; +import org.lab.domain.BugReportId; +import org.lab.domain.DomainError; +import org.lab.domain.DomainResult; +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; +import org.lab.domain.enums.BugStatus; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.concurrent.ConcurrentHashMap; + +public final class BugReportRepository { + + private final ConcurrentHashMap<BugReportId, BugReport> byId = new ConcurrentHashMap<>(); + + public BugReportId nextId() { + return BugReportId.newId(); + } + + public DomainResult<BugReport> insert(BugReport bug) { + Objects.requireNonNull(bug, "bug"); + + var prev = byId.putIfAbsent(bug.id(), bug); + if (prev != null) { + return DomainResult.err(new DomainError.Conflict("BugReport already exists: " + bug.id())); + } + return DomainResult.ok(bug); + } + + public DomainResult<BugReport> upsert(BugReport bug) { + Objects.requireNonNull(bug, "bug"); + byId.put(bug.id(), bug); + return DomainResult.ok(bug); + } + + public Optional<BugReport> findById(BugReportId id) { + Objects.requireNonNull(id, "id"); + return Optional.ofNullable(byId.get(id)); + } + + public List<BugReport> findAll() { + return byId.values().stream() + .sorted((a, b) -> a.id().toString().compareTo(b.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + + public List<BugReport> findByProject(ProjectId projectId) { + Objects.requireNonNull(projectId, "projectId"); + return byId.values().stream() + .filter(b -> b.projectId().equals(projectId)) + .sorted((a, c) -> a.id().toString().compareTo(c.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + + public List<BugReport> findByStatus(BugStatus status) { + Objects.requireNonNull(status, "status"); + return byId.values().stream() + .filter(b -> b.status() == status) + .sorted((a, c) -> a.id().toString().compareTo(c.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + + public List<BugReport> findByAssignedTo(UserId userId) { + Objects.requireNonNull(userId, "userId"); + return byId.values().stream() + .filter(b -> userId.equals(b.assignedTo())) + .sorted((a, c) -> a.id().toString().compareTo(c.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + + public List<BugReport> findToFix(UserId userId) { + Objects.requireNonNull(userId, "userId"); + return byId.values().stream() + .filter(b -> b.status() == BugStatus.NEW) + .filter(b -> userId.equals(b.assignedTo())) + .sorted((a, c) -> a.id().toString().compareTo(c.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Атомарное обновление сущности BugReport (замена record целиком). + * Сохраняем только при Success. + */ + public DomainResult<BugReport> update(BugReportId id, java.util.function.Function<BugReport, DomainResult<BugReport>> updater) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(updater, "updater"); + + final var ref = new java.util.concurrent.atomic.AtomicReference<DomainResult<BugReport>>(); + byId.compute(id, (k, old) -> { + if (old == null) { + ref.set(DomainResult.err(new DomainError.NotFound("BugReport", id.toString()))); + return null; + } + var updatedRes = updater.apply(old); + if (updatedRes == null) { + ref.set(DomainResult.err(new DomainError.InvariantViolation("repo.update", "updater returned null"))); + return old; + } + if (updatedRes.isFailure()) { + ref.set(updatedRes); + return old; + } + var updated = updatedRes.orElseThrow(); + if (!updated.id().equals(id)) { + ref.set(DomainResult.err(new DomainError.InvariantViolation("bugReport.idImmutable", "bug report id cannot change"))); + return old; + } + ref.set(DomainResult.ok(updated)); + return updated; + }); + + var res = ref.get(); + if (res == null) { + return DomainResult.err(new DomainError.InvariantViolation("repo.update", "unexpected null result")); + } + return res; + } + + public boolean delete(BugReportId id) { + Objects.requireNonNull(id, "id"); + return byId.remove(id) != null; + } +} diff --git a/src/main/java/org/lab/infra/ProjectRepository.java b/src/main/java/org/lab/infra/ProjectRepository.java new file mode 100644 index 0000000..0c06d1c --- /dev/null +++ b/src/main/java/org/lab/infra/ProjectRepository.java @@ -0,0 +1,116 @@ +package org.lab.infra; + +import org.lab.domain.DomainError; +import org.lab.domain.DomainResult; +import org.lab.domain.Project; +import org.lab.domain.ProjectId; +import org.lab.domain.UserId; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Collectors; + +public final class ProjectRepository { + + private final ConcurrentHashMap<ProjectId, Project> byId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap<String, ProjectId> idByKey = new ConcurrentHashMap<>(); + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + private final AtomicLong keySeq = new AtomicLong(0); + + public ProjectId nextId() { + return ProjectId.newId(); + } + + /** + * Генерация человекочитаемого ключа проекта (потокобезопасно). + * Пример: PRJ-000001 + */ + public String nextProjectKey() { + long n = keySeq.incrementAndGet(); + return String.format(Locale.ROOT, "PRJ-%06d", n); + } + + public DomainResult<Project> insert(Project project) { + Objects.requireNonNull(project, "project"); + + lock.writeLock().lock(); + try { + var key = project.key().value(); + var existingByKey = idByKey.get(key); + if (existingByKey != null && !existingByKey.equals(project.id())) { + return DomainResult.err(new DomainError.Conflict("Project key already exists: " + key)); + } + if (byId.containsKey(project.id())) { + return DomainResult.err(new DomainError.Conflict("Project already exists: " + project.id())); + } + + idByKey.put(key, project.id()); + byId.put(project.id(), project); + return DomainResult.ok(project); + } finally { + lock.writeLock().unlock(); + } + } + + public Optional<Project> findById(ProjectId id) { + Objects.requireNonNull(id, "id"); + return Optional.ofNullable(byId.get(id)); + } + + /** + * Modern Java: + * - Stream API: функциональная фильтрация проектов по участнику. + * - Возвращает неизменяемые коллекции через Collectors.toUnmodifiableList() (гарантия отсутствия side-effects у вызывающего кода). + */ + public List<Project> findByMember(UserId userId) { + Objects.requireNonNull(userId, "userId"); + return byId.values().stream() + .filter(p -> p.members().containsKey(userId)) + .sorted((a, b) -> a.key().value().compareToIgnoreCase(b.key().value())) + .collect(Collectors.toUnmodifiableList()); + } + + /** + * Атомарное обновление aggregate root Project: + * updater возвращает DomainResult<Project>; сохраняем только при Success. + */ + public DomainResult<Project> update(ProjectId id, java.util.function.Function<Project, DomainResult<Project>> updater) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(updater, "updater"); + + lock.writeLock().lock(); + try { + var current = byId.get(id); + if (current == null) { + return DomainResult.err(new DomainError.NotFound("Project", id.toString())); + } + + var updatedRes = updater.apply(current); + if (updatedRes.isFailure()) { + return updatedRes; + } + + var updated = updatedRes.orElseThrow(); + if (!updated.id().equals(id)) { + return DomainResult.err(new DomainError.InvariantViolation("project.idImmutable", "project id cannot change")); + } + + if (!updated.key().value().equals(current.key().value())) { + return DomainResult.err(new DomainError.InvariantViolation("project.keyImmutable", "project key cannot change")); + } + + byId.put(id, updated); + return DomainResult.ok(updated); + } finally { + lock.writeLock().unlock(); + } + } + +} diff --git a/src/main/java/org/lab/infra/TicketRepository.java b/src/main/java/org/lab/infra/TicketRepository.java new file mode 100644 index 0000000..90f800a --- /dev/null +++ b/src/main/java/org/lab/infra/TicketRepository.java @@ -0,0 +1,45 @@ +package org.lab.infra; + +import org.lab.domain.DomainError; +import org.lab.domain.DomainResult; +import org.lab.domain.MilestoneId; +import org.lab.domain.ProjectId; +import org.lab.domain.Ticket; +import org.lab.domain.TicketId; +import org.lab.domain.UserId; +import org.lab.domain.enums.TicketStatus; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public final class TicketRepository { + + private final ConcurrentHashMap<TicketId, Ticket> byId = new ConcurrentHashMap<>(); + + public TicketId nextId() { + return TicketId.newId(); + } + + public DomainResult<Ticket> upsert(Ticket ticket) { + Objects.requireNonNull(ticket, "ticket"); + byId.put(ticket.id(), ticket); + return DomainResult.ok(ticket); + } + + /** + * Modern Java: + * - Stream API: функциональные выборки/фильтрации по индексу в памяти. + * - Collectors.toUnmodifiableList(): возвращает неизменяемые результаты наружу. + */ + public List<Ticket> findByAssignee(UserId userId) { + Objects.requireNonNull(userId, "userId"); + return byId.values().stream() + .filter(t -> t.assignees().contains(userId)) + .sorted((a, b) -> a.id().toString().compareTo(b.id().toString())) + .collect(Collectors.toUnmodifiableList()); + } + +} diff --git a/src/main/java/org/lab/infra/UserRepository.java b/src/main/java/org/lab/infra/UserRepository.java new file mode 100644 index 0000000..22ecc0c --- /dev/null +++ b/src/main/java/org/lab/infra/UserRepository.java @@ -0,0 +1,75 @@ +package org.lab.infra; + +import org.lab.domain.DomainError; +import org.lab.domain.DomainResult; +import org.lab.domain.User; +import org.lab.domain.UserId; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Collectors; + +public final class UserRepository { + + private final ConcurrentHashMap<UserId, User> byId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap<String, UserId> idByLogin = new ConcurrentHashMap<>(); + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + public UserId nextId() { + return UserId.newId(); + } + + /** + * Modern Java: + * - java.util.concurrent: ConcurrentHashMap + ReadWriteLock для потокобезопасного in-memory репозитория. + * - Функциональный стиль: update принимает Function<User, DomainResult<User>> как “транзакционную” функцию обновления. + * - Возвращает DomainResult (типизированная ошибка), вместо исключений для доменных конфликтов/инвариантов. + */ + + + public DomainResult<User> insert(User user) { + Objects.requireNonNull(user, "user"); + + lock.writeLock().lock(); + try { + var existingId = idByLogin.get(user.login()); + if (existingId != null && !existingId.equals(user.id())) { + return DomainResult.err(new DomainError.Conflict("Login already exists: " + user.login())); + } + if (byId.containsKey(user.id())) { + return DomainResult.err(new DomainError.Conflict("User already exists: " + user.id())); + } + + idByLogin.put(user.login(), user.id()); + byId.put(user.id(), user); + return DomainResult.ok(user); + } finally { + lock.writeLock().unlock(); + } + } + + public Optional<User> findById(UserId id) { + Objects.requireNonNull(id, "id"); + return Optional.ofNullable(byId.get(id)); + } + + public Optional<User> findByLogin(String login) { + Objects.requireNonNull(login, "login"); + lock.readLock().lock(); + try { + var id = idByLogin.get(login); + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(byId.get(id)); + } finally { + lock.readLock().unlock(); + } + } + +}