From 75ccc7e9df2442be1363e4a601f37b2934ee970d Mon Sep 17 00:00:00 2001 From: "github-classroom[bot]" <66690702+github-classroom[bot]@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:25:26 +0000 Subject: [PATCH 1/3] 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 8f2b149236782ce1d75426c38a5ff7ed4e32d1bf Mon Sep 17 00:00:00 2001 From: Daniil Vinichenko Date: Wed, 24 Dec 2025 23:58:08 +0300 Subject: [PATCH 2/3] init commit --- .dockerignore | 27 + .env | 8 + .idea/.gitignore | 3 + .idea/.name | 1 + .idea/gradle.xml | 16 + .idea/misc.xml | 5 + .idea/vcs.xml | 6 + Dockerfile | 34 + api.yaml | 928 ++++++++++++++++++ build.gradle.kts | 29 +- docker-compose.yaml | 75 ++ gradlew | 0 init.sql | 69 ++ src/main/java/org/lab/Application.java | 13 + src/main/java/org/lab/Main.java | 4 - src/main/java/org/lab/config/CorsConfig.java | 35 + src/main/java/org/lab/domain/BugReport.java | 38 + src/main/java/org/lab/domain/BugStatus.java | 8 + .../java/org/lab/domain/DomainException.java | 7 + src/main/java/org/lab/domain/Milestone.java | 39 + .../java/org/lab/domain/MilestoneStatus.java | 7 + src/main/java/org/lab/domain/Project.java | 49 + src/main/java/org/lab/domain/ProjectRole.java | 8 + src/main/java/org/lab/domain/Ticket.java | 46 + .../java/org/lab/domain/TicketStatus.java | 8 + src/main/java/org/lab/domain/User.java | 13 + .../org/lab/repository/BugRepository.java | 67 ++ .../lab/repository/MilestoneRepository.java | 74 ++ .../org/lab/repository/ProjectRepository.java | 110 +++ .../org/lab/repository/TicketRepository.java | 80 ++ .../org/lab/repository/UserRepository.java | 44 + src/main/java/org/lab/service/BugService.java | 128 +++ .../org/lab/service/MilestoneService.java | 88 ++ .../java/org/lab/service/ProjectService.java | 98 ++ .../java/org/lab/service/TicketService.java | 124 +++ .../java/org/lab/service/UserService.java | 53 + src/main/java/org/lab/web/BugController.java | 65 ++ .../org/lab/web/GlobalExceptionHandler.java | 27 + .../java/org/lab/web/MilestoneController.java | 56 ++ .../java/org/lab/web/ProjectController.java | 50 + .../java/org/lab/web/TicketController.java | 64 ++ src/main/java/org/lab/web/UserController.java | 38 + src/main/resources/application.properties | 13 + 43 files changed, 2647 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/gradle.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 api.yaml create mode 100644 docker-compose.yaml mode change 100644 => 100755 gradlew create mode 100644 init.sql create mode 100644 src/main/java/org/lab/Application.java delete mode 100644 src/main/java/org/lab/Main.java create mode 100644 src/main/java/org/lab/config/CorsConfig.java create mode 100644 src/main/java/org/lab/domain/BugReport.java create mode 100644 src/main/java/org/lab/domain/BugStatus.java create mode 100644 src/main/java/org/lab/domain/DomainException.java create mode 100644 src/main/java/org/lab/domain/Milestone.java create mode 100644 src/main/java/org/lab/domain/MilestoneStatus.java create mode 100644 src/main/java/org/lab/domain/Project.java create mode 100644 src/main/java/org/lab/domain/ProjectRole.java create mode 100644 src/main/java/org/lab/domain/Ticket.java create mode 100644 src/main/java/org/lab/domain/TicketStatus.java create mode 100644 src/main/java/org/lab/domain/User.java create mode 100644 src/main/java/org/lab/repository/BugRepository.java create mode 100644 src/main/java/org/lab/repository/MilestoneRepository.java create mode 100644 src/main/java/org/lab/repository/ProjectRepository.java create mode 100644 src/main/java/org/lab/repository/TicketRepository.java create mode 100644 src/main/java/org/lab/repository/UserRepository.java create mode 100644 src/main/java/org/lab/service/BugService.java create mode 100644 src/main/java/org/lab/service/MilestoneService.java create mode 100644 src/main/java/org/lab/service/ProjectService.java create mode 100644 src/main/java/org/lab/service/TicketService.java create mode 100644 src/main/java/org/lab/service/UserService.java create mode 100644 src/main/java/org/lab/web/BugController.java create mode 100644 src/main/java/org/lab/web/GlobalExceptionHandler.java create mode 100644 src/main/java/org/lab/web/MilestoneController.java create mode 100644 src/main/java/org/lab/web/ProjectController.java create mode 100644 src/main/java/org/lab/web/TicketController.java create mode 100644 src/main/java/org/lab/web/UserController.java create mode 100644 src/main/resources/application.properties diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5a700e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Build outputs +build/ +.gradle/ +*.jar +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Git +.git/ +.gitignore + +# Docker +Dockerfile +docker-compose.yaml +.dockerignore + +# Other +.env +*.log +.DS_Store + diff --git a/.env b/.env new file mode 100644 index 0000000..8bbbe98 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +LOCALHOST="localhost" + +POSTGRES_USERNAME="dvpsqluser" +POSTGRES_PASSWORD="dvpsql" +POSTGRES_DATABASE="amx_data" +POSTGRES_PORT="5437" +POSTGRES_JDBC_URL="jdbc:postgresql://${LOCALHOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}" +POSTGRES_CONN="postgres://${POSTGRES_USERNAME}:${POSTGRES_PASSWORD}@${LOCALHOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}" \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..0194542 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +features \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..ce1c62c --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..60e1b11 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5f53fda --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 + +FROM eclipse-temurin:24-jdk-noble AS build +WORKDIR /app + +ENV GRADLE_USER_HOME=/home/gradle/.gradle + +COPY build.gradle.kts settings.gradle.kts ./ +COPY gradle ./gradle +COPY gradlew ./ +RUN chmod +x gradlew + +RUN --mount=type=cache,target=/home/gradle/.gradle \ + ./gradlew --no-daemon dependencies || true + +COPY src ./src + +RUN --mount=type=cache,target=/home/gradle/.gradle \ + ./gradlew --no-daemon clean bootJar -x test + +# фиксируем имя артефакта, чтобы не гадать со *-SNAPSHOT.jar +RUN set -eux; \ + JAR="$(ls -1 build/libs/*.jar | grep -v -- '-plain\.jar$' | head -n 1)"; \ + test -n "$JAR"; \ + cp "$JAR" /app/app.jar + + +FROM eclipse-temurin:24-jre +WORKDIR /app + +COPY --from=build /app/app.jar /app/app.jar + +EXPOSE 8080 +ENTRYPOINT ["java", "--enable-preview", "-jar", "/app/app.jar"] diff --git a/api.yaml b/api.yaml new file mode 100644 index 0000000..3c6d6eb --- /dev/null +++ b/api.yaml @@ -0,0 +1,928 @@ +openapi: 3.0.3 +info: + title: Lab4. Advanced Java + description: SOlution by Vinichenko Daniil + version: 1.0.0 + +servers: + - url: http://localhost:8080 + +paths: + /users: + get: + summary: Get all users + operationId: getAllUsers + tags: + - Users + responses: + '200': + description: List of users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + + post: + summary: Register new user + operationId: registerUser + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: User created + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + + /users/{login}: + get: + summary: Get user by login + operationId: getUserByLogin + tags: + - Users + parameters: + - name: login + in: path + required: true + schema: + type: string + responses: + '200': + description: User found + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + + /projects: + get: + summary: Get projects for user + operationId: getMyProjects + tags: + - Projects + parameters: + - name: user + in: query + required: true + schema: + type: string + description: User login + responses: + '200': + description: List of projects + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + '400': + $ref: '#/components/responses/BadRequest' + + post: + summary: Create new project + operationId: createProject + tags: + - Projects + parameters: + - name: user + in: query + required: true + schema: + type: string + description: User login (becomes manager) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectRequest' + responses: + '201': + description: Project created + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + $ref: '#/components/responses/BadRequest' + + /projects/{id}: + get: + summary: Get project by ID + operationId: getProjectById + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Project found + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '404': + $ref: '#/components/responses/NotFound' + + /projects/{id}/members: + post: + summary: Add member to project + operationId: addProjectMember + tags: + - Projects + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: user + in: query + required: true + schema: + type: string + description: Actor user login (must be MANAGER) + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddMemberRequest' + responses: + '200': + description: Member added + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '400': + $ref: '#/components/responses/BadRequest' + + /projects/{projectId}/milestones: + get: + summary: Get milestones for project + operationId: getMilestonesByProject + tags: + - Milestones + parameters: + - name: projectId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of milestones + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Milestone' + + post: + summary: Create milestone + operationId: createMilestone + tags: + - Milestones + parameters: + - name: projectId + in: path + required: true + schema: + type: string + format: uuid + - name: user + in: query + required: true + schema: + type: string + description: Actor user login (must be MANAGER) + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMilestoneRequest' + responses: + '201': + description: Milestone created + content: + application/json: + schema: + $ref: '#/components/schemas/Milestone' + '400': + $ref: '#/components/responses/BadRequest' + + /milestones/{id}: + get: + summary: Get milestone by ID + operationId: getMilestoneById + tags: + - Milestones + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Milestone found + content: + application/json: + schema: + $ref: '#/components/schemas/Milestone' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update milestone status (activate or close) + operationId: updateMilestoneStatus + tags: + - Milestones + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: user + in: query + required: true + schema: + type: string + description: Actor user login (must be MANAGER) + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMilestoneStatusRequest' + responses: + '200': + description: Milestone updated + content: + application/json: + schema: + $ref: '#/components/schemas/Milestone' + '400': + $ref: '#/components/responses/BadRequest' + + /milestones/{milestoneId}/tickets: + get: + summary: Get tickets for milestone + operationId: getTicketsByMilestone + tags: + - Tickets + parameters: + - name: milestoneId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of tickets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Ticket' + + /tickets: + post: + summary: Create ticket + operationId: createTicket + tags: + - Tickets + parameters: + - name: user + in: query + required: true + schema: + type: string + description: Actor user login (must be MANAGER or TEAM_LEADER) + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTicketRequest' + responses: + '201': + description: Ticket created + content: + application/json: + schema: + $ref: '#/components/schemas/Ticket' + '400': + $ref: '#/components/responses/BadRequest' + + /tickets/{id}: + get: + summary: Get ticket by ID + operationId: getTicketById + tags: + - Tickets + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Ticket found + content: + application/json: + schema: + $ref: '#/components/schemas/Ticket' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update ticket (assign or change status) + operationId: updateTicket + tags: + - Tickets + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: user + in: query + required: true + schema: + type: string + description: Actor user login + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateTicketRequest' + responses: + '200': + description: Ticket updated + content: + application/json: + schema: + $ref: '#/components/schemas/Ticket' + '400': + $ref: '#/components/responses/BadRequest' + + /my/tickets: + get: + summary: Get tickets assigned to user + operationId: getMyTickets + tags: + - Tickets + parameters: + - name: user + in: query + required: true + schema: + type: string + description: User login + responses: + '200': + description: List of tickets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Ticket' + + /projects/{projectId}/bugs: + get: + summary: Get bugs for project + operationId: getBugsByProject + tags: + - Bugs + parameters: + - name: projectId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of bug reports + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BugReport' + + /bugs: + post: + summary: Create bug report + operationId: createBug + tags: + - Bugs + parameters: + - name: user + in: query + required: true + schema: + type: string + description: Reporter user login (must be DEVELOPER, TESTER or TEAM_LEADER) + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBugRequest' + responses: + '201': + description: Bug report created + content: + application/json: + schema: + $ref: '#/components/schemas/BugReport' + '400': + $ref: '#/components/responses/BadRequest' + + /bugs/{id}: + get: + summary: Get bug report by ID + operationId: getBugById + tags: + - Bugs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Bug report found + content: + application/json: + schema: + $ref: '#/components/schemas/BugReport' + '404': + $ref: '#/components/responses/NotFound' + + patch: + summary: Update bug report (assign or change status) + operationId: updateBug + tags: + - Bugs + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + - name: user + in: query + required: true + schema: + type: string + description: Actor user login + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: Actor role + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateBugRequest' + responses: + '200': + description: Bug report updated + content: + application/json: + schema: + $ref: '#/components/schemas/BugReport' + '400': + $ref: '#/components/responses/BadRequest' + + /my/bugs: + get: + summary: Get bugs assigned to user or needing testing + operationId: getMyBugs + tags: + - Bugs + parameters: + - name: user + in: query + required: true + schema: + type: string + description: User login + - name: role + in: query + required: true + schema: + $ref: '#/components/schemas/ProjectRole' + description: User role + responses: + '200': + description: List of bug reports + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BugReport' + +components: + schemas: + User: + type: object + properties: + login: + type: string + example: joe + name: + type: string + example: Joe Peach + required: + - login + - name + + CreateUserRequest: + type: object + properties: + login: + type: string + example: joe + name: + type: string + example: Joe Peach + required: + - login + - name + + Project: + type: object + properties: + id: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + name: + type: string + example: ProjName + managerLogin: + type: string + example: joe + teamLeaderLogin: + type: string + nullable: true + example: bobs + developerLogins: + type: array + items: + type: string + example: [bobs, bebs] + testerLogins: + type: array + items: + type: string + example: [bubs] + required: + - id + - name + - managerLogin + - developerLogins + - testerLogins + + CreateProjectRequest: + type: object + properties: + name: + type: string + example: ProjName + required: + - name + + AddMemberRequest: + type: object + properties: + userLogin: + type: string + example: bobs + role: + $ref: '#/components/schemas/ProjectRole' + required: + - userLogin + - role + + ProjectRole: + type: string + enum: + - MANAGER + - TEAM_LEADER + - DEVELOPER + - TESTER + example: DEVELOPER + + Milestone: + type: object + properties: + id: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + projectId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + name: + type: string + example: Sprint 1 + startDate: + type: string + format: date + example: 2025-01-01 + endDate: + type: string + format: date + example: 2025-01-31 + status: + $ref: '#/components/schemas/MilestoneStatus' + required: + - id + - projectId + - name + - startDate + - endDate + - status + + MilestoneStatus: + type: string + enum: + - OPEN + - ACTIVE + - CLOSED + example: OPEN + + CreateMilestoneRequest: + type: object + properties: + name: + type: string + example: Sprint 1 + startDate: + type: string + format: date + example: 2025-01-01 + endDate: + type: string + format: date + example: 2025-01-31 + required: + - name + - startDate + - endDate + + UpdateMilestoneStatusRequest: + type: object + properties: + action: + type: string + enum: + - activate + - close + example: activate + required: + - action + + Ticket: + type: object + properties: + id: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + projectId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + milestoneId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + title: + type: string + example: Implement user authentication + assigneeLogins: + type: array + items: + type: string + example: [bobs] + status: + $ref: '#/components/schemas/TicketStatus' + required: + - id + - projectId + - milestoneId + - title + - assigneeLogins + - status + + TicketStatus: + type: string + enum: + - NEW + - ACCEPTED + - IN_PROGRESS + - DONE + example: NEW + + CreateTicketRequest: + type: object + properties: + projectId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + milestoneId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + title: + type: string + example: Implement user authentication + required: + - projectId + - milestoneId + - title + + UpdateTicketRequest: + type: object + properties: + assigneeLogin: + type: string + nullable: true + example: bobs + status: + $ref: '#/components/schemas/TicketStatus' + nullable: true + description: Both fields are optional. Provide assigneeLogin to assign, status to change status. + + BugReport: + type: object + properties: + id: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + projectId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + title: + type: string + example: Button not responding + reporterLogin: + type: string + example: bubs + assigneeDeveloperLogin: + type: string + nullable: true + example: bobs + status: + $ref: '#/components/schemas/BugStatus' + required: + - id + - projectId + - title + - reporterLogin + - status + + BugStatus: + type: string + enum: + - NEW + - FIXED + - TESTED + - CLOSED + example: NEW + + CreateBugRequest: + type: object + properties: + projectId: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + title: + type: string + example: Button not responding + required: + - projectId + - title + + UpdateBugRequest: + type: object + properties: + assigneeLogin: + type: string + nullable: true + example: bobs + status: + $ref: '#/components/schemas/BugStatus' + nullable: true + description: Both fields are optional. Provide assigneeLogin to assign, status to change status. + + Error: + type: object + properties: + error: + type: string + example: User already exists - joe + + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..3a72f8d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,7 @@ plugins { id("java") + id("org.springframework.boot") version "3.4.0" + id("io.spring.dependency-management") version "1.1.6" } group = "org.lab" @@ -10,11 +12,30 @@ repositories { } dependencies { - testImplementation(platform("org.junit:junit-bom:5.10.0")) - testImplementation("org.junit.jupiter:junit-jupiter") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-jdbc") + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } -tasks.test { +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(24)) + } +} + +tasks.withType().configureEach { + options.compilerArgs.add("--enable-preview") + options.release.set(24) +} + +tasks.withType().configureEach { useJUnitPlatform() -} \ No newline at end of file + jvmArgs("--enable-preview") +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview") +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..84c7d04 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,75 @@ +services: + postgres_amx: + image: postgres + container_name: postgres_amx + shm_size: 128mb + environment: + POSTGRES_HOST: "${LOCALHOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_USER: "${POSTGRES_USERNAME}" + POSTGRES_DB: "${POSTGRES_DATABASE}" + expose: + - "${POSTGRES_PORT}" + ports: + - "${POSTGRES_PORT}:${POSTGRES_PORT}" + command: -p ${POSTGRES_PORT} + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + # - /private/var/lib/postgres_amx:/var/lib/postgresql + restart: unless-stopped + networks: + - db_net + - db_net_admin + env_file: + - .env + + pm: + build: + context: . + dockerfile: Dockerfile + container_name: project_manager + ports: + - "8080:8080" + environment: + POSTGRES_HOST: "postgres_amx" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DATABASE: "${POSTGRES_DATABASE}" + POSTGRES_USERNAME: "${POSTGRES_USERNAME}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + depends_on: + - postgres_amx + restart: unless-stopped + networks: + - db_net + env_file: + - .env + + adminer: + image: adminer + container_name: adminer + restart: unless-stopped + ports: + - 5454:5454 + networks: + - db_net_admin + + swagger: + image: swaggerapi/swagger-ui:latest + container_name: swagger_ui + restart: unless-stopped + ports: + - "8081:8080" + environment: + SWAGGER_JSON: /api/api.yaml + BASE_URL: / + volumes: + - ./api.yaml:/api/api.yaml:ro + depends_on: + - pm + +networks: + db_net: + driver: bridge + db_net_admin: + driver: bridge diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..1722f79 --- /dev/null +++ b/init.sql @@ -0,0 +1,69 @@ +-- Schema for Project Management System + +-- Users +CREATE TABLE IF NOT EXISTS users ( + login VARCHAR(100) PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +-- Projects +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + manager_login VARCHAR(100) NOT NULL REFERENCES users(login) +); + +-- Project members (role per project) +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_login VARCHAR(100) NOT NULL REFERENCES users(login), + role VARCHAR(50) NOT NULL CHECK (role IN ('TEAM_LEADER', 'DEVELOPER', 'TESTER')), + PRIMARY KEY (project_id, user_login) +); + +-- Milestones +CREATE TABLE IF NOT EXISTS milestones ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + start_date DATE, + end_date DATE, + status VARCHAR(50) NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'ACTIVE', 'CLOSED')) +); + +-- Tickets +CREATE TABLE IF NOT EXISTS tickets ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + milestone_id UUID NOT NULL REFERENCES milestones(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'NEW' CHECK (status IN ('NEW', 'ACCEPTED', 'IN_PROGRESS', 'DONE')) +); + +-- Ticket assignees (many-to-many) +CREATE TABLE IF NOT EXISTS ticket_assignees ( + ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_login VARCHAR(100) NOT NULL REFERENCES users(login), + PRIMARY KEY (ticket_id, user_login) +); + +-- Bug reports +CREATE TABLE IF NOT EXISTS bug_reports ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + reporter_login VARCHAR(100) NOT NULL REFERENCES users(login), + assignee_login VARCHAR(100) REFERENCES users(login), + status VARCHAR(50) NOT NULL DEFAULT 'NEW' CHECK (status IN ('NEW', 'FIXED', 'TESTED', 'CLOSED')) +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_login); +CREATE INDEX IF NOT EXISTS idx_milestones_project ON milestones(project_id); +CREATE INDEX IF NOT EXISTS idx_tickets_milestone ON tickets(milestone_id); +CREATE INDEX IF NOT EXISTS idx_tickets_project ON tickets(project_id); +CREATE INDEX IF NOT EXISTS idx_ticket_assignees_user ON ticket_assignees(user_login); +CREATE INDEX IF NOT EXISTS idx_bug_reports_project ON bug_reports(project_id); +CREATE INDEX IF NOT EXISTS idx_bug_reports_assignee ON bug_reports(assignee_login); + + diff --git a/src/main/java/org/lab/Application.java b/src/main/java/org/lab/Application.java new file mode 100644 index 0000000..2d9192f --- /dev/null +++ b/src/main/java/org/lab/Application.java @@ -0,0 +1,13 @@ +package org.lab; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} + + diff --git a/src/main/java/org/lab/Main.java b/src/main/java/org/lab/Main.java deleted file mode 100644 index 22028ef..0000000 --- a/src/main/java/org/lab/Main.java +++ /dev/null @@ -1,4 +0,0 @@ -void main() { - IO.println("Hello and welcome!"); -} - diff --git a/src/main/java/org/lab/config/CorsConfig.java b/src/main/java/org/lab/config/CorsConfig.java new file mode 100644 index 0000000..c89a75f --- /dev/null +++ b/src/main/java/org/lab/config/CorsConfig.java @@ -0,0 +1,35 @@ +package org.lab.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(false) + .maxAge(3600); + } + + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(false); + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } +} 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..9491a84 --- /dev/null +++ b/src/main/java/org/lab/domain/BugReport.java @@ -0,0 +1,38 @@ +package org.lab.domain; + +import java.util.UUID; + +public record BugReport( + UUID id, + UUID projectId, + String title, + String reporterLogin, + String assigneeDeveloperLogin, + BugStatus status +) { + public BugReport { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + + if (projectId == null) { + throw new IllegalArgumentException("projectId must not be null"); + } + + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + + if (reporterLogin == null || reporterLogin.isBlank()) { + throw new IllegalArgumentException("reporterLogin must not be blank"); + } + + if (assigneeDeveloperLogin != null && assigneeDeveloperLogin.isBlank()) { + assigneeDeveloperLogin = null; + } + + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + } +} diff --git a/src/main/java/org/lab/domain/BugStatus.java b/src/main/java/org/lab/domain/BugStatus.java new file mode 100644 index 0000000..dae5fa7 --- /dev/null +++ b/src/main/java/org/lab/domain/BugStatus.java @@ -0,0 +1,8 @@ +package org.lab.domain; + +public enum BugStatus { + NEW, + FIXED, + TESTED, + CLOSED +} diff --git a/src/main/java/org/lab/domain/DomainException.java b/src/main/java/org/lab/domain/DomainException.java new file mode 100644 index 0000000..a3c8fe7 --- /dev/null +++ b/src/main/java/org/lab/domain/DomainException.java @@ -0,0 +1,7 @@ +package org.lab.domain; + +public class DomainException extends RuntimeException { + public DomainException(String message) { + super(message); + } +} 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..4addd03 --- /dev/null +++ b/src/main/java/org/lab/domain/Milestone.java @@ -0,0 +1,39 @@ +package org.lab.domain; + +import java.time.LocalDate; +import java.util.UUID; + +public record Milestone( + UUID id, + UUID projectId, + String name, + LocalDate startDate, + LocalDate endDate, + MilestoneStatus status +) { + public Milestone { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + + if (projectId == null) { + throw new IllegalArgumentException("projectId must not be null"); + } + + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + + if (startDate == null || endDate == null) { + throw new IllegalArgumentException("dates must not be null"); + } + + if (endDate.isBefore(startDate)) { + throw new IllegalArgumentException("endDate must be >= startDate"); + } + + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + } +} diff --git a/src/main/java/org/lab/domain/MilestoneStatus.java b/src/main/java/org/lab/domain/MilestoneStatus.java new file mode 100644 index 0000000..07bb51b --- /dev/null +++ b/src/main/java/org/lab/domain/MilestoneStatus.java @@ -0,0 +1,7 @@ +package org.lab.domain; + +public enum MilestoneStatus { + OPEN, + ACTIVE, + CLOSED +} 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..caa9985 --- /dev/null +++ b/src/main/java/org/lab/domain/Project.java @@ -0,0 +1,49 @@ +package org.lab.domain; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +public record Project( + UUID id, + String name, + String managerLogin, + String teamLeaderLogin, + Set developerLogins, + Set testerLogins +) { + public Project { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (managerLogin == null || managerLogin.isBlank()) { + throw new IllegalArgumentException("managerLogin must not be blank"); + } + + if (developerLogins == null) { + developerLogins = new LinkedHashSet<>(); + } else { + developerLogins = new LinkedHashSet<>(developerLogins); + } + + if (testerLogins == null) { + testerLogins = new LinkedHashSet<>(); + } else { + testerLogins = new LinkedHashSet<>(testerLogins); + } + + if (teamLeaderLogin != null && teamLeaderLogin.isBlank()) { + teamLeaderLogin = null; + } + } + + public boolean participates(String login) { + return managerLogin.equals(login) + || (teamLeaderLogin != null && teamLeaderLogin.equals(login)) + || developerLogins.contains(login) + || testerLogins.contains(login); + } +} diff --git a/src/main/java/org/lab/domain/ProjectRole.java b/src/main/java/org/lab/domain/ProjectRole.java new file mode 100644 index 0000000..b88bd73 --- /dev/null +++ b/src/main/java/org/lab/domain/ProjectRole.java @@ -0,0 +1,8 @@ +package org.lab.domain; + +public enum ProjectRole { + MANAGER, + TEAM_LEADER, + DEVELOPER, + TESTER +} 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..141b7bd --- /dev/null +++ b/src/main/java/org/lab/domain/Ticket.java @@ -0,0 +1,46 @@ +package org.lab.domain; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +public record Ticket( + UUID id, + UUID projectId, + UUID milestoneId, + String title, + Set assigneeLogins, + TicketStatus status +) { + public Ticket { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + + if (projectId == null) { + throw new IllegalArgumentException("projectId must not be null"); + } + + if (milestoneId == null) { + throw new IllegalArgumentException("milestoneId must not be null"); + } + + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("title must not be blank"); + } + + if (assigneeLogins == null) { + assigneeLogins = new LinkedHashSet<>(); + } else { + assigneeLogins = new LinkedHashSet<>(assigneeLogins); + } + + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } + } + + public boolean isAssignedTo(String login) { + return assigneeLogins.contains(login); + } +} diff --git a/src/main/java/org/lab/domain/TicketStatus.java b/src/main/java/org/lab/domain/TicketStatus.java new file mode 100644 index 0000000..071f0fb --- /dev/null +++ b/src/main/java/org/lab/domain/TicketStatus.java @@ -0,0 +1,8 @@ +package org.lab.domain; + +public enum TicketStatus { + NEW, + ACCEPTED, + IN_PROGRESS, + DONE +} 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..0dd3f0b --- /dev/null +++ b/src/main/java/org/lab/domain/User.java @@ -0,0 +1,13 @@ +package org.lab.domain; + +public record User(String login, String name) { + public User { + if (login == null || login.isBlank()) { + throw new IllegalArgumentException("login is empty"); + } + + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("name is empty"); + } + } +} diff --git a/src/main/java/org/lab/repository/BugRepository.java b/src/main/java/org/lab/repository/BugRepository.java new file mode 100644 index 0000000..35fc6dc --- /dev/null +++ b/src/main/java/org/lab/repository/BugRepository.java @@ -0,0 +1,67 @@ +package org.lab.repository; + +import org.lab.domain.BugReport; +import org.lab.domain.BugStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class BugRepository { + private final JdbcTemplate jdbc; + + private static final RowMapper ROW_MAPPER = (rs, _) -> new BugReport( + rs.getObject("id", UUID.class), + rs.getObject("project_id", UUID.class), + rs.getString("title"), + rs.getString("reporter_login"), + rs.getString("assignee_login"), + BugStatus.valueOf(rs.getString("status")) + ); + + public BugRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void save(BugReport b) { + jdbc.update(""" + INSERT INTO bug_reports (id, project_id, title, reporter_login, assignee_login, status) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + assignee_login = EXCLUDED.assignee_login, + status = EXCLUDED.status + """, b.id(), b.projectId(), b.title(), b.reporterLogin(), b.assigneeDeveloperLogin(), b.status().name()); + } + + public Optional findById(UUID id) { + List result = jdbc.query( + "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE id = ?", + ROW_MAPPER, id); + return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + } + + public List findByProjectId(UUID projectId) { + return jdbc.query( + "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE project_id = ? ORDER BY title", + ROW_MAPPER, projectId); + } + + public List findByAssignee(String userLogin) { + return jdbc.query( + "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE assignee_login = ? ORDER BY title", + ROW_MAPPER, userLogin); + } + + public List findNeedsTesting(UUID projectId) { + return jdbc.query( + "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE project_id = ? AND status = 'FIXED' ORDER BY title", + ROW_MAPPER, projectId); + } +} + + diff --git a/src/main/java/org/lab/repository/MilestoneRepository.java b/src/main/java/org/lab/repository/MilestoneRepository.java new file mode 100644 index 0000000..86d589d --- /dev/null +++ b/src/main/java/org/lab/repository/MilestoneRepository.java @@ -0,0 +1,74 @@ +package org.lab.repository; + +import org.lab.domain.Milestone; +import org.lab.domain.MilestoneStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class MilestoneRepository { + private final JdbcTemplate jdbc; + + private static final RowMapper ROW_MAPPER = (rs, _) -> new Milestone( + rs.getObject("id", UUID.class), + rs.getObject("project_id", UUID.class), + rs.getString("name"), + rs.getDate("start_date").toLocalDate(), + rs.getDate("end_date").toLocalDate(), + MilestoneStatus.valueOf(rs.getString("status")) + ); + + public MilestoneRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void save(Milestone m) { + jdbc.update(""" + INSERT INTO milestones (id, project_id, name, start_date, end_date, status) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + start_date = EXCLUDED.start_date, + end_date = EXCLUDED.end_date, + status = EXCLUDED.status + """, + m.id(), m.projectId(), m.name(), + Date.valueOf(m.startDate()), Date.valueOf(m.endDate()), + m.status().name()); + } + + public Optional findById(UUID id) { + List result = jdbc.query( + "SELECT id, project_id, name, start_date, end_date, status FROM milestones WHERE id = ?", + ROW_MAPPER, id); + return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + } + + public List findByProjectId(UUID projectId) { + return jdbc.query( + "SELECT id, project_id, name, start_date, end_date, status FROM milestones WHERE project_id = ? ORDER BY start_date", + ROW_MAPPER, projectId); + } + + public boolean hasCurrentMilestone(UUID projectId) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status IN ('OPEN', 'ACTIVE')", + Integer.class, projectId); + return count != null && count > 0; + } + + public boolean hasActiveMilestone(UUID projectId, UUID excludeId) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status = 'ACTIVE' AND id != ?", + Integer.class, projectId, excludeId); + return count != null && count > 0; + } +} + + diff --git a/src/main/java/org/lab/repository/ProjectRepository.java b/src/main/java/org/lab/repository/ProjectRepository.java new file mode 100644 index 0000000..f2e9c12 --- /dev/null +++ b/src/main/java/org/lab/repository/ProjectRepository.java @@ -0,0 +1,110 @@ +package org.lab.repository; + +import org.lab.domain.Project; +import org.lab.domain.ProjectRole; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.*; + +@Repository +public class ProjectRepository { + private final JdbcTemplate jdbc; + + public ProjectRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void save(Project project) { + jdbc.update(""" + INSERT INTO projects (id, name, manager_login) VALUES (?, ?, ?) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, manager_login = EXCLUDED.manager_login + """, project.id(), project.name(), project.managerLogin()); + + // Clear existing members and re-insert + jdbc.update("DELETE FROM project_members WHERE project_id = ?", project.id()); + + if (project.teamLeaderLogin() != null) { + jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?)", + project.id(), project.teamLeaderLogin(), "TEAM_LEADER"); + } + for (String dev : project.developerLogins()) { + jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + project.id(), dev, "DEVELOPER"); + } + for (String tester : project.testerLogins()) { + jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + project.id(), tester, "TESTER"); + } + } + + public Optional findById(UUID id) { + List> rows = jdbc.queryForList( + "SELECT id, name, manager_login FROM projects WHERE id = ?", id); + if (rows.isEmpty()) return Optional.empty(); + + Map row = rows.getFirst(); + return Optional.of(buildProject(row)); + } + + public List findByUserLogin(String login) { + List> rows = jdbc.queryForList(""" + SELECT DISTINCT p.id, p.name, p.manager_login FROM projects p + LEFT JOIN project_members pm ON pm.project_id = p.id + WHERE p.manager_login = ? OR pm.user_login = ? + """, login, login); + + return rows.stream().map(this::buildProject).toList(); + } + + public List findAll() { + List> rows = jdbc.queryForList("SELECT id, name, manager_login FROM projects"); + return rows.stream().map(this::buildProject).toList(); + } + + public boolean existsById(UUID id) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM projects WHERE id = ?", Integer.class, id); + return count != null && count > 0; + } + + public ProjectRole getRoleInProject(UUID projectId, String userLogin) { + List> projectRows = jdbc.queryForList( + "SELECT manager_login FROM projects WHERE id = ?", projectId); + if (projectRows.isEmpty()) return null; + + String manager = (String) projectRows.getFirst().get("manager_login"); + if (manager.equals(userLogin)) return ProjectRole.MANAGER; + + List roles = jdbc.queryForList( + "SELECT role FROM project_members WHERE project_id = ? AND user_login = ?", + String.class, projectId, userLogin); + if (roles.isEmpty()) return null; + return ProjectRole.valueOf(roles.getFirst()); + } + + private Project buildProject(Map row) { + UUID id = (UUID) row.get("id"); + String name = (String) row.get("name"); + String manager = (String) row.get("manager_login"); + + String teamLeader = null; + Set developers = new LinkedHashSet<>(); + Set testers = new LinkedHashSet<>(); + + List> members = jdbc.queryForList( + "SELECT user_login, role FROM project_members WHERE project_id = ?", id); + for (Map m : members) { + String login = (String) m.get("user_login"); + String role = (String) m.get("role"); + switch (role) { + case "TEAM_LEADER" -> teamLeader = login; + case "DEVELOPER" -> developers.add(login); + case "TESTER" -> testers.add(login); + } + } + + return new Project(id, name, manager, teamLeader, developers, testers); + } +} + + diff --git a/src/main/java/org/lab/repository/TicketRepository.java b/src/main/java/org/lab/repository/TicketRepository.java new file mode 100644 index 0000000..7398fc9 --- /dev/null +++ b/src/main/java/org/lab/repository/TicketRepository.java @@ -0,0 +1,80 @@ +package org.lab.repository; + +import org.lab.domain.Ticket; +import org.lab.domain.TicketStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.*; + +@Repository +public class TicketRepository { + private final JdbcTemplate jdbc; + + public TicketRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void save(Ticket t) { + jdbc.update(""" + INSERT INTO tickets (id, project_id, milestone_id, title, status) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + status = EXCLUDED.status + """, t.id(), t.projectId(), t.milestoneId(), t.title(), t.status().name()); + + // Sync assignees + jdbc.update("DELETE FROM ticket_assignees WHERE ticket_id = ?", t.id()); + for (String login : t.assigneeLogins()) { + jdbc.update("INSERT INTO ticket_assignees (ticket_id, user_login) VALUES (?, ?)", t.id(), login); + } + } + + public Optional findById(UUID id) { + List> rows = jdbc.queryForList( + "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE id = ?", id); + if (rows.isEmpty()) return Optional.empty(); + return Optional.of(buildTicket(rows.getFirst())); + } + + public List findByMilestoneId(UUID milestoneId) { + List> rows = jdbc.queryForList( + "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE milestone_id = ? ORDER BY title", + milestoneId); + return rows.stream().map(this::buildTicket).toList(); + } + + public List findByAssignee(String userLogin) { + List> rows = jdbc.queryForList(""" + SELECT t.id, t.project_id, t.milestone_id, t.title, t.status + FROM tickets t + JOIN ticket_assignees ta ON ta.ticket_id = t.id + WHERE ta.user_login = ? + ORDER BY t.title + """, userLogin); + return rows.stream().map(this::buildTicket).toList(); + } + + public boolean allTicketsDone(UUID milestoneId) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM tickets WHERE milestone_id = ? AND status != 'DONE'", + Integer.class, milestoneId); + return count == null || count == 0; + } + + private Ticket buildTicket(Map row) { + UUID id = (UUID) row.get("id"); + UUID projectId = (UUID) row.get("project_id"); + UUID milestoneId = (UUID) row.get("milestone_id"); + String title = (String) row.get("title"); + TicketStatus status = TicketStatus.valueOf((String) row.get("status")); + + List assignees = jdbc.queryForList( + "SELECT user_login FROM ticket_assignees WHERE ticket_id = ?", String.class, id); + + return new Ticket(id, projectId, milestoneId, title, new LinkedHashSet<>(assignees), status); + } +} + + diff --git a/src/main/java/org/lab/repository/UserRepository.java b/src/main/java/org/lab/repository/UserRepository.java new file mode 100644 index 0000000..2f01b88 --- /dev/null +++ b/src/main/java/org/lab/repository/UserRepository.java @@ -0,0 +1,44 @@ +package org.lab.repository; + +import org.lab.domain.User; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class UserRepository { + private final JdbcTemplate jdbc; + + private static final RowMapper ROW_MAPPER = (rs, _) -> + new User(rs.getString("login"), rs.getString("name")); + + public UserRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void save(User user) { + jdbc.update(""" + INSERT INTO users (login, name) VALUES (?, ?) + ON CONFLICT (login) DO UPDATE SET name = EXCLUDED.name + """, user.login(), user.name()); + } + + public Optional findByLogin(String login) { + List result = jdbc.query("SELECT login, name FROM users WHERE login = ?", ROW_MAPPER, login); + return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + } + + public boolean existsByLogin(String login) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE login = ?", Integer.class, login); + return count != null && count > 0; + } + + public List findAll() { + return jdbc.query("SELECT login, name FROM users", ROW_MAPPER); + } +} + + diff --git a/src/main/java/org/lab/service/BugService.java b/src/main/java/org/lab/service/BugService.java new file mode 100644 index 0000000..360098d --- /dev/null +++ b/src/main/java/org/lab/service/BugService.java @@ -0,0 +1,128 @@ +package org.lab.service; + +import org.lab.domain.*; +import org.lab.repository.BugRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +public class BugService { + private final BugRepository bugRepo; + private final ProjectService projectService; + private final UserService userService; + + public BugService(BugRepository bugRepo, ProjectService projectService, UserService userService) { + this.bugRepo = bugRepo; + this.projectService = projectService; + this.userService = userService; + } + + public BugReport create(UUID projectId, String title, String reporterLogin, ProjectRole reporterRole) { + if (reporterRole != ProjectRole.DEVELOPER && reporterRole != ProjectRole.TESTER && reporterRole != ProjectRole.TEAM_LEADER) { + throw new DomainException("Only developer/tester/team lead can create bug reports"); + } + if (title == null || title.isBlank()) { + throw new DomainException("Bug title is missing"); + } + + BugReport bug = new BugReport(UUID.randomUUID(), projectId, title.trim(), reporterLogin, null, BugStatus.NEW); + bugRepo.save(bug); + return bug; + } + + public BugReport getById(UUID id) { + return bugRepo.findById(id) + .orElseThrow(() -> new DomainException("Bug not found: " + id)); + } + + public List getByProjectId(UUID projectId) { + return bugRepo.findByProjectId(projectId); + } + + public List getMyBugs(String userLogin, ProjectRole role) { + List result = new ArrayList<>(); + + // Bugs assigned to me (as developer) + if (role == ProjectRole.DEVELOPER || role == ProjectRole.TEAM_LEADER) { + result.addAll(bugRepo.findByAssignee(userLogin)); + } + + return result; + } + + public List getNeedsTesting(UUID projectId) { + return bugRepo.findNeedsTesting(projectId); + } + + public BugReport assign(UUID bugId, String assigneeLogin, String actorLogin, ProjectRole actorRole) { + BugReport b = getById(bugId); + + if (b.status() == BugStatus.CLOSED) { + throw new DomainException("Cannot assign bug: already CLOSED"); + } + + boolean canAssign = actorRole == ProjectRole.MANAGER + || actorRole == ProjectRole.TEAM_LEADER + || (actorRole == ProjectRole.DEVELOPER && actorLogin.equals(assigneeLogin)); + + if (!canAssign) { + throw new DomainException("Not allowed to assign bug"); + } + + userService.requireExists(assigneeLogin); + Project project = projectService.getById(b.projectId()); + if (!project.developerLogins().contains(assigneeLogin) && + !(project.teamLeaderLogin() != null && project.teamLeaderLogin().equals(assigneeLogin))) { + throw new DomainException("User is not a developer in this project: " + assigneeLogin); + } + + BugReport updated = new BugReport(b.id(), b.projectId(), b.title(), b.reporterLogin(), assigneeLogin, b.status()); + bugRepo.save(updated); + return updated; + } + + public BugReport setStatus(UUID bugId, BugStatus newStatus, String actorLogin, ProjectRole actorRole) { + BugReport b = getById(bugId); + + if (!isAllowedTransition(b.status(), newStatus)) { + throw new DomainException("Invalid bug status transition: " + b.status() + " -> " + newStatus); + } + + boolean canChange = switch (newStatus) { + case FIXED -> { + if (b.assigneeDeveloperLogin() == null) { + throw new DomainException("Bug must be assigned before marking as FIXED"); + } + yield actorLogin.equals(b.assigneeDeveloperLogin()) + || actorRole == ProjectRole.MANAGER + || actorRole == ProjectRole.TEAM_LEADER; + } + case TESTED, CLOSED -> actorRole == ProjectRole.TESTER + || actorRole == ProjectRole.MANAGER + || actorRole == ProjectRole.TEAM_LEADER; + case NEW -> actorRole == ProjectRole.MANAGER || actorRole == ProjectRole.TEAM_LEADER; + }; + + if (!canChange) { + throw new DomainException("Not allowed to change bug status to " + newStatus); + } + + BugReport updated = new BugReport(b.id(), b.projectId(), b.title(), b.reporterLogin(), b.assigneeDeveloperLogin(), newStatus); + bugRepo.save(updated); + return updated; + } + + private boolean isAllowedTransition(BugStatus from, BugStatus to) { + return switch (from) { + case NEW -> to == BugStatus.FIXED; + case FIXED -> to == BugStatus.TESTED; + case TESTED -> to == BugStatus.CLOSED; + case CLOSED -> false; + }; + } +} + + diff --git a/src/main/java/org/lab/service/MilestoneService.java b/src/main/java/org/lab/service/MilestoneService.java new file mode 100644 index 0000000..a1c560f --- /dev/null +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -0,0 +1,88 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.Milestone; +import org.lab.domain.MilestoneStatus; +import org.lab.domain.ProjectRole; +import org.lab.repository.MilestoneRepository; +import org.lab.repository.TicketRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +public class MilestoneService { + private final MilestoneRepository milestoneRepo; + private final TicketRepository ticketRepo; + private final ProjectService projectService; + + public MilestoneService(MilestoneRepository milestoneRepo, TicketRepository ticketRepo, ProjectService projectService) { + this.milestoneRepo = milestoneRepo; + this.ticketRepo = ticketRepo; + this.projectService = projectService; + } + + public Milestone create(UUID projectId, String name, LocalDate startDate, LocalDate endDate, + String actorLogin, ProjectRole actorRole) { + projectService.requireManager(projectId, actorLogin, actorRole); + + if (name == null || name.isBlank()) { + throw new DomainException("Milestone name is missing"); + } + if (milestoneRepo.hasCurrentMilestone(projectId)) { + throw new DomainException("Project already has a current (OPEN/ACTIVE) milestone, close it first"); + } + + Milestone milestone = new Milestone(UUID.randomUUID(), projectId, name.trim(), startDate, endDate, MilestoneStatus.OPEN); + milestoneRepo.save(milestone); + return milestone; + } + + public Milestone getById(UUID id) { + return milestoneRepo.findById(id) + .orElseThrow(() -> new DomainException("Milestone not found: " + id)); + } + + public List getByProjectId(UUID projectId) { + return milestoneRepo.findByProjectId(projectId); + } + + public Milestone activate(UUID milestoneId, String actorLogin, ProjectRole actorRole) { + Milestone ms = getById(milestoneId); + projectService.requireManager(ms.projectId(), actorLogin, actorRole); + + if (ms.status() == MilestoneStatus.CLOSED) { + throw new DomainException("Milestone is CLOSED"); + } + if (ms.status() == MilestoneStatus.ACTIVE) { + return ms; + } + if (milestoneRepo.hasActiveMilestone(ms.projectId(), milestoneId)) { + throw new DomainException("Project already has an ACTIVE milestone"); + } + + Milestone updated = new Milestone(ms.id(), ms.projectId(), ms.name(), ms.startDate(), ms.endDate(), MilestoneStatus.ACTIVE); + milestoneRepo.save(updated); + return updated; + } + + public Milestone close(UUID milestoneId, String actorLogin, ProjectRole actorRole) { + Milestone ms = getById(milestoneId); + projectService.requireManager(ms.projectId(), actorLogin, actorRole); + + if (ms.status() != MilestoneStatus.ACTIVE) { + throw new DomainException("Only ACTIVE milestone can be closed"); + } + if (!ticketRepo.allTicketsDone(milestoneId)) { + throw new DomainException("Cannot close milestone, not all tickets are DONE"); + } + + Milestone updated = new Milestone(ms.id(), ms.projectId(), ms.name(), ms.startDate(), ms.endDate(), MilestoneStatus.CLOSED); + milestoneRepo.save(updated); + return updated; + } +} + + diff --git a/src/main/java/org/lab/service/ProjectService.java b/src/main/java/org/lab/service/ProjectService.java new file mode 100644 index 0000000..7123a79 --- /dev/null +++ b/src/main/java/org/lab/service/ProjectService.java @@ -0,0 +1,98 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.Project; +import org.lab.domain.ProjectRole; +import org.lab.repository.ProjectRepository; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +public class ProjectService { + private final ProjectRepository projectRepo; + private final UserService userService; + + public ProjectService(ProjectRepository projectRepo, UserService userService) { + this.projectRepo = projectRepo; + this.userService = userService; + } + + public Project create(String name, String managerLogin) { + if (name == null || name.isBlank()) { + throw new DomainException("Project name is missing"); + } + userService.requireExists(managerLogin); + + Project project = new Project(UUID.randomUUID(), name.trim(), managerLogin, null, Set.of(), Set.of()); + projectRepo.save(project); + return project; + } + + public Project getById(UUID id) { + return projectRepo.findById(id) + .orElseThrow(() -> new DomainException("Project not found: " + id)); + } + + public List getByUser(String userLogin) { + return projectRepo.findByUserLogin(userLogin); + } + + public ProjectRole getRoleInProject(UUID projectId, String userLogin) { + return projectRepo.getRoleInProject(projectId, userLogin); + } + + public Project addMember(UUID projectId, String userLogin, ProjectRole role, String actorLogin, ProjectRole actorRole) { + requireManager(projectId, actorLogin, actorRole); + userService.requireExists(userLogin); + + Project project = getById(projectId); + + return switch (role) { + case TEAM_LEADER -> { + Project updated = new Project(project.id(), project.name(), project.managerLogin(), + userLogin, project.developerLogins(), project.testerLogins()); + projectRepo.save(updated); + yield updated; + } + case DEVELOPER -> { + LinkedHashSet devs = new LinkedHashSet<>(project.developerLogins()); + devs.add(userLogin); + Project updated = new Project(project.id(), project.name(), project.managerLogin(), + project.teamLeaderLogin(), devs, project.testerLogins()); + projectRepo.save(updated); + yield updated; + } + case TESTER -> { + LinkedHashSet testers = new LinkedHashSet<>(project.testerLogins()); + testers.add(userLogin); + Project updated = new Project(project.id(), project.name(), project.managerLogin(), + project.teamLeaderLogin(), project.developerLogins(), testers); + projectRepo.save(updated); + yield updated; + } + case MANAGER -> throw new DomainException("Cannot add another manager"); + }; + } + + public void requireManager(UUID projectId, String actorLogin, ProjectRole actorRole) { + if (actorRole != ProjectRole.MANAGER) { + throw new DomainException("Only project manager can do this"); + } + Project project = getById(projectId); + if (!project.managerLogin().equals(actorLogin)) { + throw new DomainException("Only project manager can do this"); + } + } + + public void requireManagerOrLead(UUID projectId, String actorLogin, ProjectRole actorRole) { + if (actorRole != ProjectRole.MANAGER && actorRole != ProjectRole.TEAM_LEADER) { + throw new DomainException("Only manager/team leader can do this"); + } + } +} + + diff --git a/src/main/java/org/lab/service/TicketService.java b/src/main/java/org/lab/service/TicketService.java new file mode 100644 index 0000000..76b86ed --- /dev/null +++ b/src/main/java/org/lab/service/TicketService.java @@ -0,0 +1,124 @@ +package org.lab.service; + +import jakarta.annotation.Nonnull; +import org.lab.domain.*; +import org.lab.repository.TicketRepository; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Service +public class TicketService { + private final TicketRepository ticketRepo; + private final ProjectService projectService; + private final MilestoneService milestoneService; + private final UserService userService; + + public TicketService(TicketRepository ticketRepo, ProjectService projectService, + MilestoneService milestoneService, UserService userService) { + this.ticketRepo = ticketRepo; + this.projectService = projectService; + this.milestoneService = milestoneService; + this.userService = userService; + } + + public Ticket create(UUID projectId, UUID milestoneId, String title, + String actorLogin, ProjectRole actorRole) { + projectService.requireManagerOrLead(projectId, actorLogin, actorRole); + + Milestone ms = milestoneService.getById(milestoneId); + if (!ms.projectId().equals(projectId)) { + throw new DomainException("Milestone does not belong to project"); + } + if (ms.status() == MilestoneStatus.CLOSED) { + throw new DomainException("Milestone is CLOSED"); + } + if (title == null || title.isBlank()) { + throw new DomainException("Ticket title must not be blank"); + } + + Ticket ticket = new Ticket(UUID.randomUUID(), projectId, milestoneId, title.trim(), Set.of(), TicketStatus.NEW); + ticketRepo.save(ticket); + return ticket; + } + + public Ticket getById(UUID id) { + return ticketRepo.findById(id) + .orElseThrow(() -> new DomainException("Ticket not found: " + id)); + } + + public List getByMilestoneId(UUID milestoneId) { + return ticketRepo.findByMilestoneId(milestoneId); + } + + public List getByAssignee(String userLogin) { + return ticketRepo.findByAssignee(userLogin); + } + + public Ticket assign(UUID ticketId, String assigneeLogin, String actorLogin, ProjectRole actorRole) { + Ticket t = getById(ticketId); + projectService.requireManagerOrLead(t.projectId(), actorLogin, actorRole); + + Milestone ms = milestoneService.getById(t.milestoneId()); + if (ms.status() == MilestoneStatus.CLOSED) { + throw new DomainException("Cannot assign ticket: milestone is CLOSED"); + } + + userService.requireExists(assigneeLogin); + Project project = projectService.getById(t.projectId()); + Ticket updated = getTicket(assigneeLogin, project, t); + ticketRepo.save(updated); + return updated; + } + + @Nonnull + private static Ticket getTicket(String assigneeLogin, Project project, Ticket t) { + if (!project.developerLogins().contains(assigneeLogin) && + !(project.teamLeaderLogin() != null && project.teamLeaderLogin().equals(assigneeLogin))) { + throw new DomainException("User is not a developer/lead in this project: " + assigneeLogin); + } + + LinkedHashSet assignees = new LinkedHashSet<>(t.assigneeLogins()); + assignees.add(assigneeLogin); + return new Ticket(t.id(), t.projectId(), t.milestoneId(), t.title(), assignees, t.status()); + } + + public Ticket setStatus(UUID ticketId, TicketStatus newStatus, String actorLogin, ProjectRole actorRole) { + Ticket t = getById(ticketId); + + if (!isAllowedTransition(t.status(), newStatus)) { + throw new DomainException("Invalid ticket status transition: " + t.status() + " -> " + newStatus); + } + + Milestone ms = milestoneService.getById(t.milestoneId()); + if (ms.status() == MilestoneStatus.CLOSED) { + throw new DomainException("Cannot change ticket: milestone is CLOSED"); + } + + boolean canChange = actorRole == ProjectRole.MANAGER + || actorRole == ProjectRole.TEAM_LEADER + || (actorRole == ProjectRole.DEVELOPER && t.isAssignedTo(actorLogin)); + + if (!canChange) { + throw new DomainException("Not allowed to change ticket status"); + } + + Ticket updated = new Ticket(t.id(), t.projectId(), t.milestoneId(), t.title(), t.assigneeLogins(), newStatus); + ticketRepo.save(updated); + return updated; + } + + private boolean isAllowedTransition(TicketStatus from, TicketStatus to) { + return switch (from) { + case NEW -> to == TicketStatus.ACCEPTED; + case ACCEPTED -> to == TicketStatus.IN_PROGRESS; + case IN_PROGRESS -> to == TicketStatus.DONE; + case DONE -> false; + }; + } +} + + diff --git a/src/main/java/org/lab/service/UserService.java b/src/main/java/org/lab/service/UserService.java new file mode 100644 index 0000000..9e93276 --- /dev/null +++ b/src/main/java/org/lab/service/UserService.java @@ -0,0 +1,53 @@ +package org.lab.service; + +import org.lab.domain.DomainException; +import org.lab.domain.User; +import org.lab.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class UserService { + private final UserRepository userRepo; + + public UserService(UserRepository userRepo) { + this.userRepo = userRepo; + } + + public User register(String login, String name) { + if (login == null || login.isBlank()) { + throw new DomainException("login must not be blank"); + } + if (name == null || name.isBlank()) { + throw new DomainException("name must not be blank"); + } + if (userRepo.existsByLogin(login)) { + throw new DomainException("User already exists: " + login); + } + User user = new User(login, name); + userRepo.save(user); + return user; + } + + public User getByLogin(String login) { + return userRepo.findByLogin(login) + .orElseThrow(() -> new DomainException("Unknown user: " + login)); + } + + public boolean exists(String login) { + return userRepo.existsByLogin(login); + } + + public List getAll() { + return userRepo.findAll(); + } + + public void requireExists(String login) { + if (!exists(login)) { + throw new DomainException("Unknown user: " + login); + } + } +} + + diff --git a/src/main/java/org/lab/web/BugController.java b/src/main/java/org/lab/web/BugController.java new file mode 100644 index 0000000..f2a0b3e --- /dev/null +++ b/src/main/java/org/lab/web/BugController.java @@ -0,0 +1,65 @@ +package org.lab.web; + +import org.lab.domain.BugReport; +import org.lab.domain.BugStatus; +import org.lab.domain.ProjectRole; +import org.lab.service.BugService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +public class BugController { + private final BugService bugService; + + public BugController(BugService bugService) { + this.bugService = bugService; + } + + public record CreateBugRequest(UUID projectId, String title) {} + public record UpdateBugRequest(String assigneeLogin, BugStatus status) {} + + @PostMapping("/bugs") + @ResponseStatus(HttpStatus.CREATED) + public BugReport create(@RequestBody CreateBugRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + return bugService.create(request.projectId(), request.title(), user, role); + } + + @GetMapping("/projects/{projectId}/bugs") + public List getByProject(@PathVariable UUID projectId) { + return bugService.getByProjectId(projectId); + } + + @GetMapping("/bugs/{id}") + public BugReport getById(@PathVariable UUID id) { + return bugService.getById(id); + } + + @PatchMapping("/bugs/{id}") + public BugReport update(@PathVariable UUID id, + @RequestBody UpdateBugRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + BugReport result = bugService.getById(id); + + if (request.assigneeLogin() != null) { + result = bugService.assign(id, request.assigneeLogin(), user, role); + } + if (request.status() != null) { + result = bugService.setStatus(id, request.status(), user, role); + } + return result; + } + + @GetMapping("/my/bugs") + public List myBugs(@RequestParam String user, + @RequestParam ProjectRole role) { + return bugService.getMyBugs(user, role); + } +} + + diff --git a/src/main/java/org/lab/web/GlobalExceptionHandler.java b/src/main/java/org/lab/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..5985e6e --- /dev/null +++ b/src/main/java/org/lab/web/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package org.lab.web; + +import org.lab.domain.DomainException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(DomainException.class) + public ResponseEntity> handleDomainException(DomainException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", e.getMessage())); + } +} + + diff --git a/src/main/java/org/lab/web/MilestoneController.java b/src/main/java/org/lab/web/MilestoneController.java new file mode 100644 index 0000000..edf629c --- /dev/null +++ b/src/main/java/org/lab/web/MilestoneController.java @@ -0,0 +1,56 @@ +package org.lab.web; + +import org.lab.domain.Milestone; +import org.lab.domain.ProjectRole; +import org.lab.service.MilestoneService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@RestController +public class MilestoneController { + private final MilestoneService milestoneService; + + public MilestoneController(MilestoneService milestoneService) { + this.milestoneService = milestoneService; + } + + public record CreateMilestoneRequest(String name, LocalDate startDate, LocalDate endDate) {} + public record UpdateStatusRequest(String action) {} + + @GetMapping("/projects/{projectId}/milestones") + public List getByProject(@PathVariable UUID projectId) { + return milestoneService.getByProjectId(projectId); + } + + @PostMapping("/projects/{projectId}/milestones") + @ResponseStatus(HttpStatus.CREATED) + public Milestone create(@PathVariable UUID projectId, + @RequestBody CreateMilestoneRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + return milestoneService.create(projectId, request.name(), request.startDate(), request.endDate(), user, role); + } + + @GetMapping("/milestones/{id}") + public Milestone getById(@PathVariable UUID id) { + return milestoneService.getById(id); + } + + @PatchMapping("/milestones/{id}") + public Milestone updateStatus(@PathVariable UUID id, + @RequestBody UpdateStatusRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + return switch (request.action().toLowerCase()) { + case "activate" -> milestoneService.activate(id, user, role); + case "close" -> milestoneService.close(id, user, role); + default -> throw new IllegalArgumentException("Unknown action: " + request.action()); + }; + } +} + + diff --git a/src/main/java/org/lab/web/ProjectController.java b/src/main/java/org/lab/web/ProjectController.java new file mode 100644 index 0000000..9aafd26 --- /dev/null +++ b/src/main/java/org/lab/web/ProjectController.java @@ -0,0 +1,50 @@ +package org.lab.web; + +import org.lab.domain.Project; +import org.lab.domain.ProjectRole; +import org.lab.service.ProjectService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/projects") +public class ProjectController { + private final ProjectService projectService; + + public ProjectController(ProjectService projectService) { + this.projectService = projectService; + } + + public record CreateProjectRequest(String name) {} + public record AddMemberRequest(String userLogin, ProjectRole role) {} + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Project create(@RequestBody CreateProjectRequest request, + @RequestParam String user) { + return projectService.create(request.name(), user); + } + + @GetMapping + public List getMyProjects(@RequestParam String user) { + return projectService.getByUser(user); + } + + @GetMapping("/{id}") + public Project getById(@PathVariable UUID id) { + return projectService.getById(id); + } + + @PostMapping("/{id}/members") + public Project addMember(@PathVariable UUID id, + @RequestBody AddMemberRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + return projectService.addMember(id, request.userLogin(), request.role(), user, role); + } +} + + diff --git a/src/main/java/org/lab/web/TicketController.java b/src/main/java/org/lab/web/TicketController.java new file mode 100644 index 0000000..7cbac8b --- /dev/null +++ b/src/main/java/org/lab/web/TicketController.java @@ -0,0 +1,64 @@ +package org.lab.web; + +import org.lab.domain.ProjectRole; +import org.lab.domain.Ticket; +import org.lab.domain.TicketStatus; +import org.lab.service.TicketService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +public class TicketController { + private final TicketService ticketService; + + public TicketController(TicketService ticketService) { + this.ticketService = ticketService; + } + + public record CreateTicketRequest(UUID projectId, UUID milestoneId, String title) {} + public record UpdateTicketRequest(String assigneeLogin, TicketStatus status) {} + + @PostMapping("/tickets") + @ResponseStatus(HttpStatus.CREATED) + public Ticket create(@RequestBody CreateTicketRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + return ticketService.create(request.projectId(), request.milestoneId(), request.title(), user, role); + } + + @GetMapping("/milestones/{milestoneId}/tickets") + public List getByMilestone(@PathVariable UUID milestoneId) { + return ticketService.getByMilestoneId(milestoneId); + } + + @GetMapping("/tickets/{id}") + public Ticket getById(@PathVariable UUID id) { + return ticketService.getById(id); + } + + @PatchMapping("/tickets/{id}") + public Ticket update(@PathVariable UUID id, + @RequestBody UpdateTicketRequest request, + @RequestParam String user, + @RequestParam ProjectRole role) { + Ticket result = ticketService.getById(id); + + if (request.assigneeLogin() != null) { + result = ticketService.assign(id, request.assigneeLogin(), user, role); + } + if (request.status() != null) { + result = ticketService.setStatus(id, request.status(), user, role); + } + return result; + } + + @GetMapping("/my/tickets") + public List myTickets(@RequestParam String user) { + return ticketService.getByAssignee(user); + } +} + + diff --git a/src/main/java/org/lab/web/UserController.java b/src/main/java/org/lab/web/UserController.java new file mode 100644 index 0000000..fbdc427 --- /dev/null +++ b/src/main/java/org/lab/web/UserController.java @@ -0,0 +1,38 @@ +package org.lab.web; + +import org.lab.domain.User; +import org.lab.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/users") +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + public record CreateUserRequest(String login, String name) {} + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public User register(@RequestBody CreateUserRequest request) { + return userService.register(request.login(), request.name()); + } + + @GetMapping + public List getAll() { + return userService.getAll(); + } + + @GetMapping("/{login}") + public User getByLogin(@PathVariable String login) { + return userService.getByLogin(login); + } +} + + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..566ec43 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# Server +server.port=8080 + +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:${LOCALHOST:localhost}}:${POSTGRES_PORT:5437}/${POSTGRES_DATABASE:amx_data} +spring.datasource.username=${POSTGRES_USERNAME:dvpsqluser} +spring.datasource.password=${POSTGRES_PASSWORD:dvpsql} +spring.datasource.driver-class-name=org.postgresql.Driver + +# Connection pool +spring.datasource.hikari.maximum-pool-size=10 + +# Disable schema auto-generation (we use init.sql) +spring.sql.init.mode=never From 5d7895fa618e6b68852e7aa5f305650b3f7a35bf Mon Sep 17 00:00:00 2001 From: Daniil Vinichenko Date: Thu, 25 Dec 2025 15:48:46 +0300 Subject: [PATCH 3/3] some prikols --- .idea/misc.xml | 2 +- Dockerfile | 2 +- api.yaml | 13 +- build.gradle.kts | 6 - docker-compose.yaml | 6 +- src/main/java/org/lab/domain/BugReport.java | 6 +- .../java/org/lab/domain/MilestoneAction.java | 6 + src/main/java/org/lab/domain/Project.java | 22 ++-- src/main/java/org/lab/domain/Ticket.java | 8 +- .../org/lab/repository/BugRepository.java | 11 +- .../lab/repository/MilestoneRepository.java | 29 ++--- .../org/lab/repository/ProjectRepository.java | 118 +++++++++--------- .../org/lab/repository/TicketRepository.java | 54 ++++---- .../org/lab/repository/UserRepository.java | 12 +- src/main/java/org/lab/service/BugService.java | 12 +- .../org/lab/service/MilestoneService.java | 15 +-- src/main/java/org/lab/web/BugController.java | 14 +-- .../java/org/lab/web/MilestoneController.java | 10 +- .../java/org/lab/web/TicketController.java | 14 +-- 19 files changed, 182 insertions(+), 178 deletions(-) create mode 100644 src/main/java/org/lab/domain/MilestoneAction.java diff --git a/.idea/misc.xml b/.idea/misc.xml index 60e1b11..455d619 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5f53fda..89de692 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,4 @@ WORKDIR /app COPY --from=build /app/app.jar /app/app.jar EXPOSE 8080 -ENTRYPOINT ["java", "--enable-preview", "-jar", "/app/app.jar"] +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/api.yaml b/api.yaml index 3c6d6eb..a608ad3 100644 --- a/api.yaml +++ b/api.yaml @@ -759,14 +759,17 @@ components: type: object properties: action: - type: string - enum: - - activate - - close - example: activate + $ref: '#/components/schemas/MilestoneAction' required: - action + MilestoneAction: + type: string + enum: + - ACTIVATE + - CLOSE + example: ACTIVATE + Ticket: type: object properties: diff --git a/build.gradle.kts b/build.gradle.kts index 3a72f8d..98d7325 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,15 +27,9 @@ java { } tasks.withType().configureEach { - options.compilerArgs.add("--enable-preview") options.release.set(24) } tasks.withType().configureEach { useJUnitPlatform() - jvmArgs("--enable-preview") -} - -tasks.withType().configureEach { - jvmArgs("--enable-preview") } diff --git a/docker-compose.yaml b/docker-compose.yaml index 84c7d04..a7b187e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,7 +16,7 @@ services: command: -p ${POSTGRES_PORT} volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql - # - /private/var/lib/postgres_amx:/var/lib/postgresql + - postgres_data:/var/lib/postgresql/data restart: unless-stopped networks: - db_net @@ -73,3 +73,7 @@ networks: driver: bridge db_net_admin: driver: bridge + +volumes: + postgres_data: + driver: local diff --git a/src/main/java/org/lab/domain/BugReport.java b/src/main/java/org/lab/domain/BugReport.java index 9491a84..19b1e42 100644 --- a/src/main/java/org/lab/domain/BugReport.java +++ b/src/main/java/org/lab/domain/BugReport.java @@ -27,9 +27,9 @@ public record BugReport( throw new IllegalArgumentException("reporterLogin must not be blank"); } - if (assigneeDeveloperLogin != null && assigneeDeveloperLogin.isBlank()) { - assigneeDeveloperLogin = null; - } + assigneeDeveloperLogin = (assigneeDeveloperLogin != null && assigneeDeveloperLogin.isBlank()) + ? null + : assigneeDeveloperLogin; if (status == null) { throw new IllegalArgumentException("status must not be null"); diff --git a/src/main/java/org/lab/domain/MilestoneAction.java b/src/main/java/org/lab/domain/MilestoneAction.java new file mode 100644 index 0000000..9600f7d --- /dev/null +++ b/src/main/java/org/lab/domain/MilestoneAction.java @@ -0,0 +1,6 @@ +package org.lab.domain; + +public enum MilestoneAction { + ACTIVATE, + CLOSE +} diff --git a/src/main/java/org/lab/domain/Project.java b/src/main/java/org/lab/domain/Project.java index caa9985..c6370ad 100644 --- a/src/main/java/org/lab/domain/Project.java +++ b/src/main/java/org/lab/domain/Project.java @@ -23,21 +23,17 @@ public record Project( throw new IllegalArgumentException("managerLogin must not be blank"); } - if (developerLogins == null) { - developerLogins = new LinkedHashSet<>(); - } else { - developerLogins = new LinkedHashSet<>(developerLogins); - } + developerLogins = developerLogins == null + ? new LinkedHashSet<>() + : new LinkedHashSet<>(developerLogins); - if (testerLogins == null) { - testerLogins = new LinkedHashSet<>(); - } else { - testerLogins = new LinkedHashSet<>(testerLogins); - } + testerLogins = testerLogins == null + ? new LinkedHashSet<>() + : new LinkedHashSet<>(testerLogins); - if (teamLeaderLogin != null && teamLeaderLogin.isBlank()) { - teamLeaderLogin = null; - } + teamLeaderLogin = (teamLeaderLogin != null && teamLeaderLogin.isBlank()) + ? null + : teamLeaderLogin; } public boolean participates(String login) { diff --git a/src/main/java/org/lab/domain/Ticket.java b/src/main/java/org/lab/domain/Ticket.java index 141b7bd..ef70533 100644 --- a/src/main/java/org/lab/domain/Ticket.java +++ b/src/main/java/org/lab/domain/Ticket.java @@ -29,11 +29,9 @@ public record Ticket( throw new IllegalArgumentException("title must not be blank"); } - if (assigneeLogins == null) { - assigneeLogins = new LinkedHashSet<>(); - } else { - assigneeLogins = new LinkedHashSet<>(assigneeLogins); - } + assigneeLogins = assigneeLogins == null + ? new LinkedHashSet<>() + : new LinkedHashSet<>(assigneeLogins); if (status == null) { throw new IllegalArgumentException("status must not be null"); diff --git a/src/main/java/org/lab/repository/BugRepository.java b/src/main/java/org/lab/repository/BugRepository.java index 35fc6dc..67e4405 100644 --- a/src/main/java/org/lab/repository/BugRepository.java +++ b/src/main/java/org/lab/repository/BugRepository.java @@ -39,10 +39,11 @@ ON CONFLICT (id) DO UPDATE SET } public Optional findById(UUID id) { - List result = jdbc.query( - "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE id = ?", - ROW_MAPPER, id); - return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + return jdbc.query( + "SELECT id, project_id, title, reporter_login, assignee_login, status FROM bug_reports WHERE id = ?", + ROW_MAPPER, id) + .stream() + .findFirst(); } public List findByProjectId(UUID projectId) { @@ -63,5 +64,3 @@ public List findNeedsTesting(UUID projectId) { ROW_MAPPER, projectId); } } - - diff --git a/src/main/java/org/lab/repository/MilestoneRepository.java b/src/main/java/org/lab/repository/MilestoneRepository.java index 86d589d..6968e15 100644 --- a/src/main/java/org/lab/repository/MilestoneRepository.java +++ b/src/main/java/org/lab/repository/MilestoneRepository.java @@ -44,10 +44,11 @@ ON CONFLICT (id) DO UPDATE SET } public Optional findById(UUID id) { - List result = jdbc.query( - "SELECT id, project_id, name, start_date, end_date, status FROM milestones WHERE id = ?", - ROW_MAPPER, id); - return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + return jdbc.query( + "SELECT id, project_id, name, start_date, end_date, status FROM milestones WHERE id = ?", + ROW_MAPPER, id) + .stream() + .findFirst(); } public List findByProjectId(UUID projectId) { @@ -57,18 +58,18 @@ public List findByProjectId(UUID projectId) { } public boolean hasCurrentMilestone(UUID projectId) { - Integer count = jdbc.queryForObject( - "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status IN ('OPEN', 'ACTIVE')", - Integer.class, projectId); - return count != null && count > 0; + return Optional.ofNullable( + jdbc.queryForObject( + "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status IN ('OPEN', 'ACTIVE')", + Integer.class, projectId) + ).map(count -> count > 0).orElse(false); } public boolean hasActiveMilestone(UUID projectId, UUID excludeId) { - Integer count = jdbc.queryForObject( - "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status = 'ACTIVE' AND id != ?", - Integer.class, projectId, excludeId); - return count != null && count > 0; + return Optional.ofNullable( + jdbc.queryForObject( + "SELECT COUNT(*) FROM milestones WHERE project_id = ? AND status = 'ACTIVE' AND id != ?", + Integer.class, projectId, excludeId) + ).map(count -> count > 0).orElse(false); } } - - diff --git a/src/main/java/org/lab/repository/ProjectRepository.java b/src/main/java/org/lab/repository/ProjectRepository.java index f2e9c12..4fcb247 100644 --- a/src/main/java/org/lab/repository/ProjectRepository.java +++ b/src/main/java/org/lab/repository/ProjectRepository.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Repository; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; @Repository public class ProjectRepository { @@ -21,65 +23,68 @@ INSERT INTO projects (id, name, manager_login) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, manager_login = EXCLUDED.manager_login """, project.id(), project.name(), project.managerLogin()); - // Clear existing members and re-insert jdbc.update("DELETE FROM project_members WHERE project_id = ?", project.id()); - if (project.teamLeaderLogin() != null) { - jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?)", - project.id(), project.teamLeaderLogin(), "TEAM_LEADER"); - } - for (String dev : project.developerLogins()) { - jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", - project.id(), dev, "DEVELOPER"); - } - for (String tester : project.testerLogins()) { - jdbc.update("INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", - project.id(), tester, "TESTER"); - } + Optional.ofNullable(project.teamLeaderLogin()) + .ifPresent(lead -> jdbc.update( + "INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?)", + project.id(), lead, ProjectRole.TEAM_LEADER.name())); + + Stream.concat( + project.developerLogins().stream() + .map(dev -> new Object[]{project.id(), dev, ProjectRole.DEVELOPER.name()}), + project.testerLogins().stream() + .map(tester -> new Object[]{project.id(), tester, ProjectRole.TESTER.name()}) + ).forEach(params -> jdbc.update( + "INSERT INTO project_members (project_id, user_login, role) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + params)); } public Optional findById(UUID id) { - List> rows = jdbc.queryForList( - "SELECT id, name, manager_login FROM projects WHERE id = ?", id); - if (rows.isEmpty()) return Optional.empty(); - - Map row = rows.getFirst(); - return Optional.of(buildProject(row)); + return jdbc.queryForList("SELECT id, name, manager_login FROM projects WHERE id = ?", id) + .stream() + .findFirst() + .map(this::buildProject); } public List findByUserLogin(String login) { - List> rows = jdbc.queryForList(""" + return jdbc.queryForList(""" SELECT DISTINCT p.id, p.name, p.manager_login FROM projects p LEFT JOIN project_members pm ON pm.project_id = p.id WHERE p.manager_login = ? OR pm.user_login = ? - """, login, login); - - return rows.stream().map(this::buildProject).toList(); + """, login, login) + .stream() + .map(this::buildProject) + .toList(); } public List findAll() { - List> rows = jdbc.queryForList("SELECT id, name, manager_login FROM projects"); - return rows.stream().map(this::buildProject).toList(); + return jdbc.queryForList("SELECT id, name, manager_login FROM projects") + .stream() + .map(this::buildProject) + .toList(); } public boolean existsById(UUID id) { - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM projects WHERE id = ?", Integer.class, id); - return count != null && count > 0; + return Optional.ofNullable( + jdbc.queryForObject("SELECT COUNT(*) FROM projects WHERE id = ?", Integer.class, id) + ).map(count -> count > 0).orElse(false); } public ProjectRole getRoleInProject(UUID projectId, String userLogin) { - List> projectRows = jdbc.queryForList( - "SELECT manager_login FROM projects WHERE id = ?", projectId); - if (projectRows.isEmpty()) return null; - - String manager = (String) projectRows.getFirst().get("manager_login"); - if (manager.equals(userLogin)) return ProjectRole.MANAGER; - - List roles = jdbc.queryForList( - "SELECT role FROM project_members WHERE project_id = ? AND user_login = ?", - String.class, projectId, userLogin); - if (roles.isEmpty()) return null; - return ProjectRole.valueOf(roles.getFirst()); + return jdbc.queryForList("SELECT manager_login FROM projects WHERE id = ?", projectId) + .stream() + .findFirst() + .map(row -> (String) row.get("manager_login")) + .filter(manager -> manager.equals(userLogin)) + .map(_ -> ProjectRole.MANAGER) + .orElseGet(() -> jdbc.queryForList( + "SELECT role FROM project_members WHERE project_id = ? AND user_login = ?", + String.class, projectId, userLogin) + .stream() + .findFirst() + .map(ProjectRole::valueOf) + .orElse(null)); } private Project buildProject(Map row) { @@ -87,24 +92,25 @@ private Project buildProject(Map row) { String name = (String) row.get("name"); String manager = (String) row.get("manager_login"); - String teamLeader = null; - Set developers = new LinkedHashSet<>(); - Set testers = new LinkedHashSet<>(); - - List> members = jdbc.queryForList( - "SELECT user_login, role FROM project_members WHERE project_id = ?", id); - for (Map m : members) { - String login = (String) m.get("user_login"); - String role = (String) m.get("role"); - switch (role) { - case "TEAM_LEADER" -> teamLeader = login; - case "DEVELOPER" -> developers.add(login); - case "TESTER" -> testers.add(login); - } - } + Map> membersByRole = jdbc.queryForList( + "SELECT user_login, role FROM project_members WHERE project_id = ?", id) + .stream() + .collect(Collectors.groupingBy( + memberRow -> ProjectRole.valueOf((String) memberRow.get("role")), + Collectors.mapping( + memberRow -> (String) memberRow.get("user_login"), + Collectors.toList()))); + + String teamLeader = membersByRole.getOrDefault(ProjectRole.TEAM_LEADER, List.of()) + .stream() + .findFirst() + .orElse(null); + + Set developers = new LinkedHashSet<>( + membersByRole.getOrDefault(ProjectRole.DEVELOPER, List.of())); + Set testers = new LinkedHashSet<>( + membersByRole.getOrDefault(ProjectRole.TESTER, List.of())); return new Project(id, name, manager, teamLeader, developers, testers); } } - - diff --git a/src/main/java/org/lab/repository/TicketRepository.java b/src/main/java/org/lab/repository/TicketRepository.java index 7398fc9..5a5d88f 100644 --- a/src/main/java/org/lab/repository/TicketRepository.java +++ b/src/main/java/org/lab/repository/TicketRepository.java @@ -24,43 +24,49 @@ ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status """, t.id(), t.projectId(), t.milestoneId(), t.title(), t.status().name()); - // Sync assignees jdbc.update("DELETE FROM ticket_assignees WHERE ticket_id = ?", t.id()); - for (String login : t.assigneeLogins()) { - jdbc.update("INSERT INTO ticket_assignees (ticket_id, user_login) VALUES (?, ?)", t.id(), login); - } + t.assigneeLogins().stream() + .forEach(login -> jdbc.update( + "INSERT INTO ticket_assignees (ticket_id, user_login) VALUES (?, ?)", + t.id(), login)); } public Optional findById(UUID id) { - List> rows = jdbc.queryForList( - "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE id = ?", id); - if (rows.isEmpty()) return Optional.empty(); - return Optional.of(buildTicket(rows.getFirst())); + return jdbc.queryForList( + "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE id = ?", id) + .stream() + .findFirst() + .map(this::buildTicket); } public List findByMilestoneId(UUID milestoneId) { - List> rows = jdbc.queryForList( - "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE milestone_id = ? ORDER BY title", - milestoneId); - return rows.stream().map(this::buildTicket).toList(); + return jdbc.queryForList( + "SELECT id, project_id, milestone_id, title, status FROM tickets WHERE milestone_id = ? ORDER BY title", + milestoneId) + .stream() + .map(this::buildTicket) + .toList(); } public List findByAssignee(String userLogin) { - List> rows = jdbc.queryForList(""" + return jdbc.queryForList(""" SELECT t.id, t.project_id, t.milestone_id, t.title, t.status FROM tickets t JOIN ticket_assignees ta ON ta.ticket_id = t.id WHERE ta.user_login = ? ORDER BY t.title - """, userLogin); - return rows.stream().map(this::buildTicket).toList(); + """, userLogin) + .stream() + .map(this::buildTicket) + .toList(); } public boolean allTicketsDone(UUID milestoneId) { - Integer count = jdbc.queryForObject( - "SELECT COUNT(*) FROM tickets WHERE milestone_id = ? AND status != 'DONE'", - Integer.class, milestoneId); - return count == null || count == 0; + return Optional.ofNullable( + jdbc.queryForObject( + "SELECT COUNT(*) FROM tickets WHERE milestone_id = ? AND status != 'DONE'", + Integer.class, milestoneId) + ).map(count -> count == 0).orElse(true); } private Ticket buildTicket(Map row) { @@ -70,11 +76,11 @@ private Ticket buildTicket(Map row) { String title = (String) row.get("title"); TicketStatus status = TicketStatus.valueOf((String) row.get("status")); - List assignees = jdbc.queryForList( - "SELECT user_login FROM ticket_assignees WHERE ticket_id = ?", String.class, id); + Set assignees = jdbc.queryForList( + "SELECT user_login FROM ticket_assignees WHERE ticket_id = ?", String.class, id) + .stream() + .collect(LinkedHashSet::new, LinkedHashSet::add, LinkedHashSet::addAll); - return new Ticket(id, projectId, milestoneId, title, new LinkedHashSet<>(assignees), status); + return new Ticket(id, projectId, milestoneId, title, assignees, status); } } - - diff --git a/src/main/java/org/lab/repository/UserRepository.java b/src/main/java/org/lab/repository/UserRepository.java index 2f01b88..51d3ac9 100644 --- a/src/main/java/org/lab/repository/UserRepository.java +++ b/src/main/java/org/lab/repository/UserRepository.java @@ -27,18 +27,18 @@ ON CONFLICT (login) DO UPDATE SET name = EXCLUDED.name } public Optional findByLogin(String login) { - List result = jdbc.query("SELECT login, name FROM users WHERE login = ?", ROW_MAPPER, login); - return result.isEmpty() ? Optional.empty() : Optional.of(result.getFirst()); + return jdbc.query("SELECT login, name FROM users WHERE login = ?", ROW_MAPPER, login) + .stream() + .findFirst(); } public boolean existsByLogin(String login) { - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE login = ?", Integer.class, login); - return count != null && count > 0; + return Optional.ofNullable( + jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE login = ?", Integer.class, login) + ).map(count -> count > 0).orElse(false); } public List findAll() { return jdbc.query("SELECT login, name FROM users", ROW_MAPPER); } } - - diff --git a/src/main/java/org/lab/service/BugService.java b/src/main/java/org/lab/service/BugService.java index 360098d..591e0ed 100644 --- a/src/main/java/org/lab/service/BugService.java +++ b/src/main/java/org/lab/service/BugService.java @@ -4,7 +4,6 @@ import org.lab.repository.BugRepository; import org.springframework.stereotype.Service; -import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -43,14 +42,9 @@ public List getByProjectId(UUID projectId) { } public List getMyBugs(String userLogin, ProjectRole role) { - List result = new ArrayList<>(); - - // Bugs assigned to me (as developer) - if (role == ProjectRole.DEVELOPER || role == ProjectRole.TEAM_LEADER) { - result.addAll(bugRepo.findByAssignee(userLogin)); - } - - return result; + return (role == ProjectRole.DEVELOPER || role == ProjectRole.TEAM_LEADER) + ? bugRepo.findByAssignee(userLogin) + : List.of(); } public List getNeedsTesting(UUID projectId) { diff --git a/src/main/java/org/lab/service/MilestoneService.java b/src/main/java/org/lab/service/MilestoneService.java index a1c560f..752226f 100644 --- a/src/main/java/org/lab/service/MilestoneService.java +++ b/src/main/java/org/lab/service/MilestoneService.java @@ -56,14 +56,17 @@ public Milestone activate(UUID milestoneId, String actorLogin, ProjectRole actor if (ms.status() == MilestoneStatus.CLOSED) { throw new DomainException("Milestone is CLOSED"); } - if (ms.status() == MilestoneStatus.ACTIVE) { - return ms; - } if (milestoneRepo.hasActiveMilestone(ms.projectId(), milestoneId)) { throw new DomainException("Project already has an ACTIVE milestone"); } - Milestone updated = new Milestone(ms.id(), ms.projectId(), ms.name(), ms.startDate(), ms.endDate(), MilestoneStatus.ACTIVE); + return ms.status() == MilestoneStatus.ACTIVE + ? ms + : updateMilestoneStatus(ms, MilestoneStatus.ACTIVE); + } + + private Milestone updateMilestoneStatus(Milestone ms, MilestoneStatus newStatus) { + Milestone updated = new Milestone(ms.id(), ms.projectId(), ms.name(), ms.startDate(), ms.endDate(), newStatus); milestoneRepo.save(updated); return updated; } @@ -79,9 +82,7 @@ public Milestone close(UUID milestoneId, String actorLogin, ProjectRole actorRol throw new DomainException("Cannot close milestone, not all tickets are DONE"); } - Milestone updated = new Milestone(ms.id(), ms.projectId(), ms.name(), ms.startDate(), ms.endDate(), MilestoneStatus.CLOSED); - milestoneRepo.save(updated); - return updated; + return updateMilestoneStatus(ms, MilestoneStatus.CLOSED); } } diff --git a/src/main/java/org/lab/web/BugController.java b/src/main/java/org/lab/web/BugController.java index f2a0b3e..63db392 100644 --- a/src/main/java/org/lab/web/BugController.java +++ b/src/main/java/org/lab/web/BugController.java @@ -45,14 +45,12 @@ public BugReport update(@PathVariable UUID id, @RequestParam String user, @RequestParam ProjectRole role) { BugReport result = bugService.getById(id); - - if (request.assigneeLogin() != null) { - result = bugService.assign(id, request.assigneeLogin(), user, role); - } - if (request.status() != null) { - result = bugService.setStatus(id, request.status(), user, role); - } - return result; + result = request.assigneeLogin() != null + ? bugService.assign(id, request.assigneeLogin(), user, role) + : result; + return request.status() != null + ? bugService.setStatus(id, request.status(), user, role) + : result; } @GetMapping("/my/bugs") diff --git a/src/main/java/org/lab/web/MilestoneController.java b/src/main/java/org/lab/web/MilestoneController.java index edf629c..2d11d2e 100644 --- a/src/main/java/org/lab/web/MilestoneController.java +++ b/src/main/java/org/lab/web/MilestoneController.java @@ -1,6 +1,7 @@ package org.lab.web; import org.lab.domain.Milestone; +import org.lab.domain.MilestoneAction; import org.lab.domain.ProjectRole; import org.lab.service.MilestoneService; import org.springframework.http.HttpStatus; @@ -19,7 +20,7 @@ public MilestoneController(MilestoneService milestoneService) { } public record CreateMilestoneRequest(String name, LocalDate startDate, LocalDate endDate) {} - public record UpdateStatusRequest(String action) {} + public record UpdateStatusRequest(MilestoneAction action) {} @GetMapping("/projects/{projectId}/milestones") public List getByProject(@PathVariable UUID projectId) { @@ -45,10 +46,9 @@ public Milestone updateStatus(@PathVariable UUID id, @RequestBody UpdateStatusRequest request, @RequestParam String user, @RequestParam ProjectRole role) { - return switch (request.action().toLowerCase()) { - case "activate" -> milestoneService.activate(id, user, role); - case "close" -> milestoneService.close(id, user, role); - default -> throw new IllegalArgumentException("Unknown action: " + request.action()); + return switch (request.action()) { + case ACTIVATE -> milestoneService.activate(id, user, role); + case CLOSE -> milestoneService.close(id, user, role); }; } } diff --git a/src/main/java/org/lab/web/TicketController.java b/src/main/java/org/lab/web/TicketController.java index 7cbac8b..4c4b4b4 100644 --- a/src/main/java/org/lab/web/TicketController.java +++ b/src/main/java/org/lab/web/TicketController.java @@ -45,14 +45,12 @@ public Ticket update(@PathVariable UUID id, @RequestParam String user, @RequestParam ProjectRole role) { Ticket result = ticketService.getById(id); - - if (request.assigneeLogin() != null) { - result = ticketService.assign(id, request.assigneeLogin(), user, role); - } - if (request.status() != null) { - result = ticketService.setStatus(id, request.status(), user, role); - } - return result; + result = request.assigneeLogin() != null + ? ticketService.assign(id, request.assigneeLogin(), user, role) + : result; + return request.status() != null + ? ticketService.setStatus(id, request.status(), user, role) + : result; } @GetMapping("/my/tickets")