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..455d619
--- /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..89de692
--- /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", "-jar", "/app/app.jar"]
diff --git a/README.md b/README.md
index 4a80115..19aec56 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+[](https://classroom.github.com/a/TvkQWWs6)
# Features of modern Java
# Цели и задачи л/р:
diff --git a/api.yaml b/api.yaml
new file mode 100644
index 0000000..a608ad3
--- /dev/null
+++ b/api.yaml
@@ -0,0 +1,931 @@
+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:
+ $ref: '#/components/schemas/MilestoneAction'
+ required:
+ - action
+
+ MilestoneAction:
+ type: string
+ enum:
+ - ACTIVATE
+ - CLOSE
+ example: ACTIVATE
+
+ 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..98d7325 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,24 @@ 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.release.set(24)
+}
+
+tasks.withType().configureEach {
useJUnitPlatform()
-}
\ No newline at end of file
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..a7b187e
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,79 @@
+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
+ - postgres_data:/var/lib/postgresql/data
+ 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
+
+volumes:
+ postgres_data:
+ driver: local
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..19b1e42
--- /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");
+ }
+
+ 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/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/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/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..c6370ad
--- /dev/null
+++ b/src/main/java/org/lab/domain/Project.java
@@ -0,0 +1,45 @@
+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");
+ }
+
+ developerLogins = developerLogins == null
+ ? new LinkedHashSet<>()
+ : new LinkedHashSet<>(developerLogins);
+
+ testerLogins = testerLogins == null
+ ? new LinkedHashSet<>()
+ : new LinkedHashSet<>(testerLogins);
+
+ teamLeaderLogin = (teamLeaderLogin != null && teamLeaderLogin.isBlank())
+ ? null
+ : teamLeaderLogin;
+ }
+
+ 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..ef70533
--- /dev/null
+++ b/src/main/java/org/lab/domain/Ticket.java
@@ -0,0 +1,44 @@
+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");
+ }
+
+ assigneeLogins = assigneeLogins == null
+ ? new LinkedHashSet<>()
+ : 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..67e4405
--- /dev/null
+++ b/src/main/java/org/lab/repository/BugRepository.java
@@ -0,0 +1,66 @@
+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) {
+ 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) {
+ 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..6968e15
--- /dev/null
+++ b/src/main/java/org/lab/repository/MilestoneRepository.java
@@ -0,0 +1,75 @@
+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) {
+ 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) {
+ 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) {
+ 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) {
+ 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
new file mode 100644
index 0000000..4fcb247
--- /dev/null
+++ b/src/main/java/org/lab/repository/ProjectRepository.java
@@ -0,0 +1,116 @@
+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.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+@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());
+
+ jdbc.update("DELETE FROM project_members WHERE project_id = ?", project.id());
+
+ 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) {
+ return jdbc.queryForList("SELECT id, name, manager_login FROM projects WHERE id = ?", id)
+ .stream()
+ .findFirst()
+ .map(this::buildProject);
+ }
+
+ public List findByUserLogin(String login) {
+ 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)
+ .stream()
+ .map(this::buildProject)
+ .toList();
+ }
+
+ public List findAll() {
+ return jdbc.queryForList("SELECT id, name, manager_login FROM projects")
+ .stream()
+ .map(this::buildProject)
+ .toList();
+ }
+
+ public boolean existsById(UUID id) {
+ 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) {
+ 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) {
+ UUID id = (UUID) row.get("id");
+ String name = (String) row.get("name");
+ String manager = (String) row.get("manager_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
new file mode 100644
index 0000000..5a5d88f
--- /dev/null
+++ b/src/main/java/org/lab/repository/TicketRepository.java
@@ -0,0 +1,86 @@
+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());
+
+ jdbc.update("DELETE FROM ticket_assignees WHERE ticket_id = ?", t.id());
+ t.assigneeLogins().stream()
+ .forEach(login -> jdbc.update(
+ "INSERT INTO ticket_assignees (ticket_id, user_login) VALUES (?, ?)",
+ t.id(), login));
+ }
+
+ public Optional findById(UUID id) {
+ 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) {
+ 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) {
+ 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)
+ .stream()
+ .map(this::buildTicket)
+ .toList();
+ }
+
+ public boolean allTicketsDone(UUID milestoneId) {
+ 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) {
+ 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"));
+
+ 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, 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..51d3ac9
--- /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) {
+ return jdbc.query("SELECT login, name FROM users WHERE login = ?", ROW_MAPPER, login)
+ .stream()
+ .findFirst();
+ }
+
+ public boolean existsByLogin(String login) {
+ 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
new file mode 100644
index 0000000..591e0ed
--- /dev/null
+++ b/src/main/java/org/lab/service/BugService.java
@@ -0,0 +1,122 @@
+package org.lab.service;
+
+import org.lab.domain.*;
+import org.lab.repository.BugRepository;
+import org.springframework.stereotype.Service;
+
+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) {
+ return (role == ProjectRole.DEVELOPER || role == ProjectRole.TEAM_LEADER)
+ ? bugRepo.findByAssignee(userLogin)
+ : List.of();
+ }
+
+ 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..752226f
--- /dev/null
+++ b/src/main/java/org/lab/service/MilestoneService.java
@@ -0,0 +1,89 @@
+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 (milestoneRepo.hasActiveMilestone(ms.projectId(), milestoneId)) {
+ throw new DomainException("Project already has an ACTIVE milestone");
+ }
+
+ 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;
+ }
+
+ 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");
+ }
+
+ return updateMilestoneStatus(ms, MilestoneStatus.CLOSED);
+ }
+}
+
+
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..63db392
--- /dev/null
+++ b/src/main/java/org/lab/web/BugController.java
@@ -0,0 +1,63 @@
+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);
+ 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")
+ 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