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 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](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> 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..2d11d2e --- /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.MilestoneAction; +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(MilestoneAction 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()) { + case ACTIVATE -> milestoneService.activate(id, user, role); + case CLOSE -> milestoneService.close(id, user, role); + }; + } +} + + 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..4c4b4b4 --- /dev/null +++ b/src/main/java/org/lab/web/TicketController.java @@ -0,0 +1,62 @@ +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); + 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") + 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