diff --git a/build.gradle.kts b/build.gradle.kts index 79bf52a..aabcdf9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,20 +1,56 @@ plugins { - id("java") + java + id("org.springframework.boot") version "3.3.5" + id("io.spring.dependency-management") version "1.1.6" } -group = "org.lab" -version = "1.0-SNAPSHOT" +group = "com.example" +version = "0.0.1" -repositories { - mavenCentral() -} dependencies { - testImplementation(platform("org.junit:junit-bom:5.10.0")) - testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.projectlombok:lombok") + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + + testImplementation("org.springframework.security:spring-security-test") + developmentOnly("org.springframework.boot:spring-boot-devtools") + testImplementation("org.springframework.boot:spring-boot-starter-test") + runtimeOnly("org.postgresql:postgresql") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + implementation("org.json:json:20231013") + testImplementation("org.junit.jupiter:junit-jupiter:5.9.1") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(23) + } +} + +tasks.withType().configureEach { + useJUnitPlatform() + + testLogging { + events("passed", "skipped", "failed") + } +} + +tasks.withType().configureEach { + options.compilerArgs.add("--enable-preview") +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview") } -tasks.test { - useJUnitPlatform() +tasks.withType().configureEach { + jvmArgs("--enable-preview") } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..7f93135 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae2431a..9355b41 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Thu Nov 27 19:51:20 MSK 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 10d1b39..7440912 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,7 @@ -rootProject.name = "features" \ No newline at end of file +rootProject.name = "features" + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} diff --git a/src/main/java/com/example/Application.java b/src/main/java/com/example/Application.java new file mode 100644 index 0000000..136e11a --- /dev/null +++ b/src/main/java/com/example/Application.java @@ -0,0 +1,13 @@ +package com.example; + +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/com/example/config/JwtAuthenticationFilter.java b/src/main/java/com/example/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..37d8469 --- /dev/null +++ b/src/main/java/com/example/config/JwtAuthenticationFilter.java @@ -0,0 +1,65 @@ +package com.example.config; + +import com.example.services.JwtService; +import com.example.services.UserService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + public static final String BEARER_PREFIX = "Bearer "; + public static final String HEADER_NAME = "Authorization"; + private final JwtService jwtService; + private final UserService userService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + var authHeader = request.getHeader(HEADER_NAME); + if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWithIgnoreCase(authHeader, BEARER_PREFIX)) { + filterChain.doFilter(request, response); + return; + } + + var jwt = authHeader.substring(BEARER_PREFIX.length()); + var username = jwtService.extractUserName(jwt); + + if (!StringUtils.isEmpty(username) && SecurityContextHolder.getContext().getAuthentication() == null) { + var userDetails = userService + .userDetailsService() + .loadUserByUsername(username); + + if (jwtService.isTokenValid(jwt, userDetails)) { + var context = SecurityContextHolder.createEmptyContext(); + + var authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + context.setAuthentication(authToken); + SecurityContextHolder.setContext(context); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/example/config/SecurityConfiguration.java b/src/main/java/com/example/config/SecurityConfiguration.java new file mode 100644 index 0000000..310e698 --- /dev/null +++ b/src/main/java/com/example/config/SecurityConfiguration.java @@ -0,0 +1,64 @@ +package com.example.config; + +import com.example.services.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +@Configuration +@EnableWebSecurity(debug = true) +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfiguration { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final UserService userService; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(request -> request + .requestMatchers("/users/login", "/users/register", "/error").permitAll() + + .anyRequest().authenticated() + ) + .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS)) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userService.userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/example/controllers/BugReportController.java b/src/main/java/com/example/controllers/BugReportController.java new file mode 100644 index 0000000..281a235 --- /dev/null +++ b/src/main/java/com/example/controllers/BugReportController.java @@ -0,0 +1,52 @@ +package com.example.controllers; + +import com.example.dto.project.BugReportDto; +import com.example.dto.project.StatusDto; +import com.example.model.enums.BugReportStatus; +import com.example.services.ProjectService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Arrays; +import java.util.List; + +@RestController +@RequestMapping("/bugreports") +@RequiredArgsConstructor +public class BugReportController { + + private final ProjectService projectService; + + @GetMapping + public List getBugReports(@AuthenticationPrincipal UserDetails user) { + return projectService.findBugReportsForUser(user.getUsername()).stream() + .map(br -> + new BugReportDto( + br.getId(), + br.getDescription(), + br.getStatus() + ) + ) + .toList(); + } + + @PutMapping("/{bugReportId}/status") + public void updateStatus( + @PathVariable("bugReportId") Long bugReportId, + @RequestBody StatusDto statusDto, + @AuthenticationPrincipal UserDetails userDetails + ) { + projectService.updateBugReportStatus(bugReportId, toBugReportStatus(statusDto), userDetails.getUsername()); + } + + private BugReportStatus toBugReportStatus(StatusDto statusDto) { + return Arrays.stream(BugReportStatus.values()) + .filter(it -> it.getValue().equals(statusDto.status())) + .findAny() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "status not found")); + } +} diff --git a/src/main/java/com/example/controllers/MilestoneController.java b/src/main/java/com/example/controllers/MilestoneController.java new file mode 100644 index 0000000..2c518e2 --- /dev/null +++ b/src/main/java/com/example/controllers/MilestoneController.java @@ -0,0 +1,42 @@ +package com.example.controllers; + +import com.example.dto.project.StatusDto; +import com.example.dto.ticket.CreateTicketDto; +import com.example.dto.ticket.TicketIdDto; +import com.example.model.enums.MilestoneStatus; +import com.example.services.ProjectService; +import com.example.services.TicketService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Arrays; + +@RestController +@RequestMapping("/milestones") +@RequiredArgsConstructor +public class MilestoneController { + + private final TicketService ticketService; + private final ProjectService projectService; + + @PostMapping("/{milestoneId}/tickets") + public ResponseEntity createTicket(@RequestBody CreateTicketDto createTicketDto, @PathVariable Long milestoneId) { + var ticket = ticketService.createTicket(createTicketDto, milestoneId); + return new ResponseEntity<>(new TicketIdDto(ticket.getId().toString()), HttpStatus.CREATED); + } + + @PutMapping("/{milestoneId}/status") + public void updateStatus(@PathVariable Long milestoneId, @RequestBody StatusDto statusDto) { + projectService.updateMilestoneStatus(milestoneId, toMilestoneStatus(statusDto)); + } + + private MilestoneStatus toMilestoneStatus(StatusDto statusDto) { + return Arrays.stream(MilestoneStatus.values()) + .filter(it -> it.getValue().equals(statusDto.status())) + .findAny() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "status not found")); + } +} diff --git a/src/main/java/com/example/controllers/ProjectController.java b/src/main/java/com/example/controllers/ProjectController.java new file mode 100644 index 0000000..4c8e740 --- /dev/null +++ b/src/main/java/com/example/controllers/ProjectController.java @@ -0,0 +1,93 @@ +package com.example.controllers; + +import com.example.dto.project.*; +import com.example.services.ProjectService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@RequestMapping("/projects") +@RequiredArgsConstructor +public class ProjectController { + + private final ProjectService projectService; + + @GetMapping + public List getAllProjects( + @AuthenticationPrincipal UserDetails userDetails + ) { + return projectService.findProjectsForUser(userDetails.getUsername()).stream() + .map(entity -> + new ProjectDto(entity.getId(), entity.getProjectName(), entity.getDescription()) + ) + .toList(); + } + + @PostMapping + public ResponseEntity createProject( + @RequestBody CreateProjectDto projectDto, + @AuthenticationPrincipal UserDetails userDetails + ) { + var project = projectService.create(projectDto, userDetails.getUsername()); + return new ResponseEntity<>(new ProjectIdDto(project.getId().toString()), HttpStatus.CREATED); + } + + @PostMapping("/{projectId}/teamleader") + public ResponseEntity setTeamLeader(@RequestBody UserIdDto userIdDto, @PathVariable Long projectId) { + projectService.setTeamleader(projectId, userIdDto.userId()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{projectId}/developers") + public ResponseEntity addDeveloper(@RequestBody UserIdDto userIdDto, @PathVariable Long projectId) { + projectService.addDeveloper(projectId, userIdDto.userId()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{projectId}/testers") + public ResponseEntity addTester(@RequestBody UserIdDto userIdDto, @PathVariable Long projectId) { + projectService.addTester(projectId, userIdDto.userId()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{projectId}/milestones") + public ResponseEntity addMilestone( + @RequestBody CreateMilestoneDto milestoneDto, + @PathVariable Long projectId, + @AuthenticationPrincipal UserDetails userDetails + ) { + var milestone = projectService.addMilestone(projectId, milestoneDto, userDetails.getUsername()); + return new ResponseEntity<>(new MilestoneIdDto(milestone.getId().toString()), HttpStatus.CREATED); + } + + @PostMapping("/{projectId}/test") + public void testProject( + @PathVariable String projectId, + @AuthenticationPrincipal UserDetails userDetails + ) { + var projectIdLong = parseLongOrNull(projectId); + if (projectIdLong == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found"); + projectService.testProject(projectIdLong, userDetails.getUsername()); + } + + @PostMapping("/{projectId}/bugreports") + public ResponseEntity addBugReport(@RequestBody CreateBugReportDto createBugReportDto, @PathVariable Long projectId) { + var bugReport = projectService.addBugReport(projectId, createBugReportDto); + return new ResponseEntity<>(new BugReportIdDto(bugReport.getId().toString()), HttpStatus.CREATED); + } + + private Long parseLongOrNull(String number) { + try { + return Long.parseLong(number); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/example/controllers/TicketController.java b/src/main/java/com/example/controllers/TicketController.java new file mode 100644 index 0000000..54e09cc --- /dev/null +++ b/src/main/java/com/example/controllers/TicketController.java @@ -0,0 +1,67 @@ +package com.example.controllers; + +import com.example.dto.project.TicketDto; +import com.example.dto.project.UserIdDto; +import com.example.dto.ticket.TicketStatusDto; +import com.example.model.enums.TicketStatus; +import com.example.services.TicketService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Arrays; +import java.util.List; + +@RestController +@RequestMapping("/tickets") +@RequiredArgsConstructor +public class TicketController { + + private final TicketService ticketService; + + @GetMapping + public List getAllTickets( + @AuthenticationPrincipal UserDetails userDetails + ) { + return ticketService.getAllTicketsForUser(userDetails.getUsername()).stream() + .map(ticket -> + new TicketDto( + ticket.getId(), + ticket.getTitle(), + ticket.getDescription(), + ticket.getStatus() + ) + ) + .toList(); + } + + @PostMapping("/{ticketId}/assign") + public void assignTicket( + @RequestBody UserIdDto userIdDto, + @PathVariable Long ticketId, + @AuthenticationPrincipal UserDetails userDetails + ) { + ticketService.assignTicketToUser(ticketId, userIdDto.userId(), userDetails.getUsername()); + } + + @PutMapping("/{ticketId}/status") + public void changeStatus(@RequestBody TicketStatusDto statusDto, @PathVariable Long ticketId) { + ticketService.changeStatus(ticketId, toTicketStatus(statusDto)); + } + + @GetMapping("/{ticketId}/status") + public TicketStatusDto getStatus(@PathVariable Long ticketId) { + var status = ticketService.getStatus(ticketId); + return new TicketStatusDto(status.getValue()); + } + + private TicketStatus toTicketStatus(TicketStatusDto statusDto) { + return Arrays.stream(TicketStatus.values()) + .filter(it -> it.getValue().equals(statusDto.status())) + .findAny() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "status not found")); + } +} diff --git a/src/main/java/com/example/controllers/UserController.java b/src/main/java/com/example/controllers/UserController.java new file mode 100644 index 0000000..81a6d46 --- /dev/null +++ b/src/main/java/com/example/controllers/UserController.java @@ -0,0 +1,31 @@ +package com.example.controllers; + +import com.example.dto.auth.LogInDto; +import com.example.dto.auth.RegisterDto; +import com.example.dto.auth.TokenDto; +import com.example.services.AuthenticationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + + private final AuthenticationService authenticationService; + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterDto registerDto) { + authenticationService.register(registerDto); + return new ResponseEntity(HttpStatus.CREATED); + } + + @PostMapping("/login") + public TokenDto login(@RequestBody LogInDto loginDto) { + var token = authenticationService.loginIn(loginDto); + return new TokenDto(token); + } + +} diff --git a/src/main/java/com/example/dto/auth/LogInDto.java b/src/main/java/com/example/dto/auth/LogInDto.java new file mode 100644 index 0000000..446cbac --- /dev/null +++ b/src/main/java/com/example/dto/auth/LogInDto.java @@ -0,0 +1,7 @@ +package com.example.dto.auth; + +public record LogInDto( + String username, + String password +) { +} diff --git a/src/main/java/com/example/dto/auth/RegisterDto.java b/src/main/java/com/example/dto/auth/RegisterDto.java new file mode 100644 index 0000000..1203899 --- /dev/null +++ b/src/main/java/com/example/dto/auth/RegisterDto.java @@ -0,0 +1,8 @@ +package com.example.dto.auth; + +public record RegisterDto( + String username, + String password, + String email +) { +} diff --git a/src/main/java/com/example/dto/auth/TokenDto.java b/src/main/java/com/example/dto/auth/TokenDto.java new file mode 100644 index 0000000..7a8c047 --- /dev/null +++ b/src/main/java/com/example/dto/auth/TokenDto.java @@ -0,0 +1,6 @@ +package com.example.dto.auth; + +public record TokenDto( + String token +) { +} diff --git a/src/main/java/com/example/dto/project/BugReportDto.java b/src/main/java/com/example/dto/project/BugReportDto.java new file mode 100644 index 0000000..2151613 --- /dev/null +++ b/src/main/java/com/example/dto/project/BugReportDto.java @@ -0,0 +1,10 @@ +package com.example.dto.project; + +import com.example.model.enums.BugReportStatus; + +public record BugReportDto( + Long id, + String description, + BugReportStatus status +) { +} diff --git a/src/main/java/com/example/dto/project/BugReportIdDto.java b/src/main/java/com/example/dto/project/BugReportIdDto.java new file mode 100644 index 0000000..d7aaf11 --- /dev/null +++ b/src/main/java/com/example/dto/project/BugReportIdDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record BugReportIdDto( + String bugReportId +) { +} diff --git a/src/main/java/com/example/dto/project/CreateBugReportDto.java b/src/main/java/com/example/dto/project/CreateBugReportDto.java new file mode 100644 index 0000000..d580102 --- /dev/null +++ b/src/main/java/com/example/dto/project/CreateBugReportDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record CreateBugReportDto( + String description +) { +} diff --git a/src/main/java/com/example/dto/project/CreateMilestoneDto.java b/src/main/java/com/example/dto/project/CreateMilestoneDto.java new file mode 100644 index 0000000..2335448 --- /dev/null +++ b/src/main/java/com/example/dto/project/CreateMilestoneDto.java @@ -0,0 +1,8 @@ +package com.example.dto.project; + +public record CreateMilestoneDto( + String name, + String startDate, + String endDate +) { +} diff --git a/src/main/java/com/example/dto/project/CreateProjectDto.java b/src/main/java/com/example/dto/project/CreateProjectDto.java new file mode 100644 index 0000000..5877fec --- /dev/null +++ b/src/main/java/com/example/dto/project/CreateProjectDto.java @@ -0,0 +1,7 @@ +package com.example.dto.project; + +public record CreateProjectDto( + String projectName, + String description +) { +} diff --git a/src/main/java/com/example/dto/project/MilestoneDto.java b/src/main/java/com/example/dto/project/MilestoneDto.java new file mode 100644 index 0000000..73c0e60 --- /dev/null +++ b/src/main/java/com/example/dto/project/MilestoneDto.java @@ -0,0 +1,12 @@ +package com.example.dto.project; + +import com.example.model.Project; + +public record MilestoneDto( + Long id, + String name, + String startDate, + String endDate, + Project project +) { +} diff --git a/src/main/java/com/example/dto/project/MilestoneIdDto.java b/src/main/java/com/example/dto/project/MilestoneIdDto.java new file mode 100644 index 0000000..0d6e49c --- /dev/null +++ b/src/main/java/com/example/dto/project/MilestoneIdDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record MilestoneIdDto( + String milestoneId +) { +} diff --git a/src/main/java/com/example/dto/project/ProjectDto.java b/src/main/java/com/example/dto/project/ProjectDto.java new file mode 100644 index 0000000..73c26ab --- /dev/null +++ b/src/main/java/com/example/dto/project/ProjectDto.java @@ -0,0 +1,4 @@ +package com.example.dto.project; + +public record ProjectDto(Long id, String projectName, String description) { +} diff --git a/src/main/java/com/example/dto/project/ProjectIdDto.java b/src/main/java/com/example/dto/project/ProjectIdDto.java new file mode 100644 index 0000000..bc71396 --- /dev/null +++ b/src/main/java/com/example/dto/project/ProjectIdDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record ProjectIdDto( + String projectId +) { +} diff --git a/src/main/java/com/example/dto/project/StatusDto.java b/src/main/java/com/example/dto/project/StatusDto.java new file mode 100644 index 0000000..8e6fee8 --- /dev/null +++ b/src/main/java/com/example/dto/project/StatusDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record StatusDto( + String status +) { +} diff --git a/src/main/java/com/example/dto/project/TicketDto.java b/src/main/java/com/example/dto/project/TicketDto.java new file mode 100644 index 0000000..5d914d8 --- /dev/null +++ b/src/main/java/com/example/dto/project/TicketDto.java @@ -0,0 +1,12 @@ +package com.example.dto.project; + + +import com.example.model.enums.TicketStatus; + +public record TicketDto( + Long id, + String title, + String description, + TicketStatus status +) { +} diff --git a/src/main/java/com/example/dto/project/UserIdDto.java b/src/main/java/com/example/dto/project/UserIdDto.java new file mode 100644 index 0000000..adcb02e --- /dev/null +++ b/src/main/java/com/example/dto/project/UserIdDto.java @@ -0,0 +1,6 @@ +package com.example.dto.project; + +public record UserIdDto( + String userId +) { +} diff --git a/src/main/java/com/example/dto/ticket/CreateTicketDto.java b/src/main/java/com/example/dto/ticket/CreateTicketDto.java new file mode 100644 index 0000000..f69c0b7 --- /dev/null +++ b/src/main/java/com/example/dto/ticket/CreateTicketDto.java @@ -0,0 +1,7 @@ +package com.example.dto.ticket; + +public record CreateTicketDto( + String title, + String description +) { +} diff --git a/src/main/java/com/example/dto/ticket/TicketIdDto.java b/src/main/java/com/example/dto/ticket/TicketIdDto.java new file mode 100644 index 0000000..14fe05a --- /dev/null +++ b/src/main/java/com/example/dto/ticket/TicketIdDto.java @@ -0,0 +1,6 @@ +package com.example.dto.ticket; + +public record TicketIdDto( + String ticketId +) { +} diff --git a/src/main/java/com/example/dto/ticket/TicketStatusDto.java b/src/main/java/com/example/dto/ticket/TicketStatusDto.java new file mode 100644 index 0000000..2f65910 --- /dev/null +++ b/src/main/java/com/example/dto/ticket/TicketStatusDto.java @@ -0,0 +1,6 @@ +package com.example.dto.ticket; + +public record TicketStatusDto( + String status +) { +} diff --git a/src/main/java/com/example/model/BugReport.java b/src/main/java/com/example/model/BugReport.java new file mode 100644 index 0000000..25efd81 --- /dev/null +++ b/src/main/java/com/example/model/BugReport.java @@ -0,0 +1,36 @@ +package com.example.model; + +import com.example.model.enums.BugReportStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "bug_reports") +@Getter +@Setter +@NoArgsConstructor +public class BugReport { + + @Id + @GeneratedValue + private Long id; + + @Column(name = "description") + private String description; + + @ManyToOne + @JoinColumn(name = "project_id") + private Project project; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private BugReportStatus status; + + public BugReport(String description, Project project) { + this.description = description; + this.project = project; + this.status = BugReportStatus.OPEN; + } +} diff --git a/src/main/java/com/example/model/Milestone.java b/src/main/java/com/example/model/Milestone.java new file mode 100644 index 0000000..0e26c57 --- /dev/null +++ b/src/main/java/com/example/model/Milestone.java @@ -0,0 +1,49 @@ +package com.example.model; + +import com.example.model.enums.MilestoneStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Entity +@Table(name = "milestones") +@Getter +@Setter +@NoArgsConstructor +public class Milestone { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @Column(name = "name") + private String name; + + @Column(name = "start_date") + private String startDate; + + @Column(name = "end_date") + private String endDate; + + @ManyToOne + @JoinColumn(name = "project_id") + private Project project; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private MilestoneStatus status; + + @OneToMany(mappedBy = "milestone") + private List tickets; + + public Milestone(String name, String startDate, String endDate, Project project) { + this.name = name; + this.startDate = startDate; + this.endDate = endDate; + this.project = project; + this.status = MilestoneStatus.OPEN; + } +} diff --git a/src/main/java/com/example/model/Project.java b/src/main/java/com/example/model/Project.java new file mode 100644 index 0000000..eb13738 --- /dev/null +++ b/src/main/java/com/example/model/Project.java @@ -0,0 +1,49 @@ +package com.example.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Entity +@Table(name = "projects") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Project { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + @Column(name = "id") + private Long id; + + @Column(name = "project_name") + private String projectName; + + @Column(name = "description") + private String description; + + @ManyToOne + @JoinColumn(name = "manager_id") + private User manager; + + @ManyToOne + @JoinColumn(name = "teamleader_id") + private User teamleader; + + @ManyToMany + private List developers; + + @ManyToMany + private List testers; + + public Project(String projectName, String description, User manager) { + this.projectName = projectName; + this.description = description; + this.manager = manager; + } +} diff --git a/src/main/java/com/example/model/Ticket.java b/src/main/java/com/example/model/Ticket.java new file mode 100644 index 0000000..0c99f09 --- /dev/null +++ b/src/main/java/com/example/model/Ticket.java @@ -0,0 +1,44 @@ +package com.example.model; + +import com.example.model.enums.TicketStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "tickets") +@Getter +@Setter +@NoArgsConstructor +public class Ticket { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @Column(name = "title") + private String title; + + @Column(name = "description") + private String description; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private TicketStatus status; + + @ManyToOne + @JoinColumn(name = "user_id") + private User assignedUser; + + @ManyToOne + @JoinColumn(name = "milestone_id") + private Milestone milestone; + + public Ticket(String title, String description, Milestone milestone) { + this.title = title; + this.description = description; + this.status = TicketStatus.NEW; + this.milestone = milestone; + } +} diff --git a/src/main/java/com/example/model/User.java b/src/main/java/com/example/model/User.java new file mode 100644 index 0000000..10519e9 --- /dev/null +++ b/src/main/java/com/example/model/User.java @@ -0,0 +1,44 @@ +package com.example.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +public class User implements UserDetails { + + public User(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + } + + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.SEQUENCE) + private Long id; + + @Column(name = "username") + private String username; + + @Column(name = "email") + private String email; + + @Column(name = "password") + private String password; + + @Override + public Collection getAuthorities() { + return List.of(); + } +} diff --git a/src/main/java/com/example/model/enums/BugReportStatus.java b/src/main/java/com/example/model/enums/BugReportStatus.java new file mode 100644 index 0000000..29234b9 --- /dev/null +++ b/src/main/java/com/example/model/enums/BugReportStatus.java @@ -0,0 +1,19 @@ +package com.example.model.enums; + +public enum BugReportStatus { + + OPEN("open"), + CLOSED("closed"), + TESTED("tested"), + FIXED("fixed"); + + private final String value; + + BugReportStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/example/model/enums/MilestoneStatus.java b/src/main/java/com/example/model/enums/MilestoneStatus.java new file mode 100644 index 0000000..fe76c59 --- /dev/null +++ b/src/main/java/com/example/model/enums/MilestoneStatus.java @@ -0,0 +1,17 @@ +package com.example.model.enums; + +public enum MilestoneStatus { + OPEN("open"), + ACTIVE("active"), + CLOSED("closed"); + + private final String value; + + MilestoneStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/example/model/enums/TicketStatus.java b/src/main/java/com/example/model/enums/TicketStatus.java new file mode 100644 index 0000000..62a391d --- /dev/null +++ b/src/main/java/com/example/model/enums/TicketStatus.java @@ -0,0 +1,18 @@ +package com.example.model.enums; + +public enum TicketStatus { + NEW("new"), + ACCEPTED("accepted"), + IN_PROGRESS("in_progress"), + COMPLETED("completed"); + + private final String value; + + TicketStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/example/repo/BugReportRepository.java b/src/main/java/com/example/repo/BugReportRepository.java new file mode 100644 index 0000000..64c7a8e --- /dev/null +++ b/src/main/java/com/example/repo/BugReportRepository.java @@ -0,0 +1,9 @@ +package com.example.repo; + +import com.example.model.BugReport; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BugReportRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/repo/MilestoneRepository.java b/src/main/java/com/example/repo/MilestoneRepository.java new file mode 100644 index 0000000..ba765f4 --- /dev/null +++ b/src/main/java/com/example/repo/MilestoneRepository.java @@ -0,0 +1,9 @@ +package com.example.repo; + +import com.example.model.Milestone; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MilestoneRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/repo/ProjectRepository.java b/src/main/java/com/example/repo/ProjectRepository.java new file mode 100644 index 0000000..c2acf3e --- /dev/null +++ b/src/main/java/com/example/repo/ProjectRepository.java @@ -0,0 +1,16 @@ +package com.example.repo; + +import com.example.model.Project; +import com.example.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProjectRepository extends JpaRepository { + + @Query(value = "update Project p set p.teamleader = ?2 where p.id = ?1") + @Modifying + void setTeamleader(Long projectId, User teamleader); +} diff --git a/src/main/java/com/example/repo/TicketRepository.java b/src/main/java/com/example/repo/TicketRepository.java new file mode 100644 index 0000000..0bc3a6f --- /dev/null +++ b/src/main/java/com/example/repo/TicketRepository.java @@ -0,0 +1,12 @@ +package com.example.repo; + +import com.example.model.Ticket; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface TicketRepository extends JpaRepository { + List findAllByAssignedUser_Username(String username); +} diff --git a/src/main/java/com/example/repo/UserRepository.java b/src/main/java/com/example/repo/UserRepository.java new file mode 100644 index 0000000..aa002fb --- /dev/null +++ b/src/main/java/com/example/repo/UserRepository.java @@ -0,0 +1,16 @@ +package com.example.repo; + +import com.example.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + boolean existsByUsername(String username); + + boolean existsByEmail(String email); + + Optional findByUsername(String username); +} diff --git a/src/main/java/com/example/services/AuthenticationService.java b/src/main/java/com/example/services/AuthenticationService.java new file mode 100644 index 0000000..b9883a2 --- /dev/null +++ b/src/main/java/com/example/services/AuthenticationService.java @@ -0,0 +1,43 @@ +package com.example.services; + +import com.example.dto.auth.LogInDto; +import com.example.dto.auth.RegisterDto; +import com.example.model.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + private final UserService userService; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + + public void register(RegisterDto request) { + + var user = new User( + request.username(), + request.email(), + passwordEncoder.encode(request.password()) + ); + + userService.create(user); + } + + public String loginIn(LogInDto request) { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + request.username(), + request.password() + )); + + var user = userService + .userDetailsService() + .loadUserByUsername(request.username()); + + return jwtService.generateToken(user); + } +} diff --git a/src/main/java/com/example/services/JwtService.java b/src/main/java/com/example/services/JwtService.java new file mode 100644 index 0000000..30f7b2b --- /dev/null +++ b/src/main/java/com/example/services/JwtService.java @@ -0,0 +1,71 @@ +package com.example.services; + +import com.example.model.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Service +public class JwtService { + @Value("${token.signing.key}") + private String jwtSigningKey; + + public String extractUserName(String token) { + return extractClaim(token, Claims::getSubject); // feature 1.1 + } + + public String generateToken(UserDetails userDetails) { + var claims = new HashMap(); // feature 1.3 + if (userDetails instanceof User customUserDetails) { + claims.put("id", customUserDetails.getId()); + claims.put("email", customUserDetails.getEmail()); + } + return generateToken(claims, userDetails); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String userName = extractUserName(token); + return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + private T extractClaim(String token, Function claimsResolvers) { + final Claims claims = extractAllClaims(token); + return claimsResolvers.apply(claims); + } + + private String generateToken(Map extraClaims, UserDetails userDetails) { + return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + 100000 * 60)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact(); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); // feature 1.1 + } + + private Claims extractAllClaims(String token) { + return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token) + .getBody(); + } + + private Key getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/com/example/services/ProjectService.java b/src/main/java/com/example/services/ProjectService.java new file mode 100644 index 0000000..5014896 --- /dev/null +++ b/src/main/java/com/example/services/ProjectService.java @@ -0,0 +1,188 @@ +package com.example.services; + +import com.example.dto.project.CreateBugReportDto; +import com.example.dto.project.CreateMilestoneDto; +import com.example.dto.project.CreateProjectDto; +import com.example.model.BugReport; +import com.example.model.Milestone; +import com.example.model.Project; +import com.example.model.enums.BugReportStatus; +import com.example.model.enums.MilestoneStatus; +import com.example.repo.BugReportRepository; +import com.example.repo.MilestoneRepository; +import com.example.repo.ProjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.StructuredTaskScope; + +import static com.example.model.enums.BugReportStatus.*; +import static com.example.model.enums.TicketStatus.COMPLETED; + +@Service +@RequiredArgsConstructor +public class ProjectService { + + private final ProjectRepository projectRepository; + private final MilestoneRepository milestoneRepository; + private final BugReportRepository bugReportRepository; + private final UserService userService; + + public Project create(CreateProjectDto dto, String creatorUsername) { + var creator = userService.getByUsername(creatorUsername); + return projectRepository.save( + new Project( + dto.projectName(), + dto.description(), + creator + ) + ); + } + + public List findProjectsForUser(String username) { + return projectRepository.findAll().stream() + .filter(project -> + isManager(username, project) || isTeamleader(username, project) || isDeveloper(username, project) || isTester(username, project) + ) + .toList(); + } + + public List findBugReportsForUser(String username) { + return bugReportRepository.findAll(); + } + + @SuppressWarnings("preview") + @Transactional + public void setTeamleader(Long projectId, String username) { + try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { + var project = scope.fork(() -> projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")) + ); + var user = scope.fork(() -> userService.getByUsername(username)); + scope.join(); + scope.throwIfFailed(); + projectRepository.setTeamleader(project.get().getId(), user.get()); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Transactional + public void addDeveloper(Long projectId, String username) { + var project = projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")); + var user = userService.getByUsername(username); + project.getDevelopers().add(user); + projectRepository.save(project); + } + + @Transactional + public void addTester(Long projectId, String username) { + var project = projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")); + var user = userService.getByUsername(username); + project.getTesters().add(user); + projectRepository.save(project); + } + + @Transactional + public Milestone addMilestone(Long projectId, CreateMilestoneDto milestoneDto, String requesterUsername) { + var project = projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")); + if (!project.getManager().getUsername().equals(requesterUsername)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return milestoneRepository.save(new Milestone( + milestoneDto.name(), + milestoneDto.startDate(), + milestoneDto.endDate(), + project + )); + } + + public Milestone getMilestone(Long milestoneId) { + return milestoneRepository.findById(milestoneId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "milestone not found")); + } + + @Transactional + public BugReport addBugReport(Long projectId, CreateBugReportDto dto) { + var project = projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")); + return bugReportRepository.save(new BugReport( + dto.description(), + project + )); + } + + @Transactional + public void updateBugReportStatus(Long bugReportId, BugReportStatus status, String username) { + var bugReport = bugReportRepository.findById(bugReportId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "bug report not found")); + + var project = bugReport.getProject(); + + if (!isTester(username, project) && !isDeveloper(username, project)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + + if (isTester(username, project) && !List.of(OPEN, TESTED, CLOSED).contains(status)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tester can either open or test a bugreport"); + } + + if (isDeveloper(username, project) && status != FIXED) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tester can only fix a bugreport"); + } + + bugReport.setStatus(status); + bugReportRepository.save(bugReport); + } + + @Transactional + public void updateMilestoneStatus(Long milestoneId, MilestoneStatus status) { + var milestone = milestoneRepository.findById(milestoneId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "milestone not found")); + + if (Objects.requireNonNull(status) == MilestoneStatus.CLOSED) { + var allTicketsCompleted = + milestone.getTickets().stream().allMatch(ticket -> ticket.getStatus() == COMPLETED); + + if (!allTicketsCompleted) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "All milestone tickets should be completed"); + } + } + milestone.setStatus(status); + milestoneRepository.save(milestone); + } + + public void testProject(Long projectId, String username) { + var project = projectRepository.findById(projectId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "project not found")); + project.getTesters().stream() + .filter(tester -> tester.getUsername().equals(username)) + .findAny() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN)); + } + + private boolean isTester(String username, Project project) { + return project.getTesters().stream() + .anyMatch(tester -> tester.getUsername().equals(username)); + } + + private boolean isDeveloper(String username, Project project) { + return project.getDevelopers().stream() + .anyMatch(dev -> dev.getUsername().equals(username)); + } + + private boolean isTeamleader(String username, Project project) { + return project.getTeamleader().getUsername().equals(username); + } + + private boolean isManager(String username, Project project) { + return project.getManager().getUsername().equals(username); + } +} diff --git a/src/main/java/com/example/services/TicketService.java b/src/main/java/com/example/services/TicketService.java new file mode 100644 index 0000000..57ac4b8 --- /dev/null +++ b/src/main/java/com/example/services/TicketService.java @@ -0,0 +1,63 @@ +package com.example.services; + +import com.example.dto.ticket.CreateTicketDto; +import com.example.model.Ticket; +import com.example.model.enums.TicketStatus; +import com.example.repo.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static com.example.model.enums.MilestoneStatus.CLOSED; + +@Service +@RequiredArgsConstructor +public class TicketService { + + private final TicketRepository ticketRepository; + private final UserService userService; + private final ProjectService projectService; + + public List getAllTicketsForUser(String username) { + return ticketRepository.findAllByAssignedUser_Username(username); + } + + @Transactional + public Ticket createTicket(CreateTicketDto ticketDto, Long milestoneId) { + var milestone = projectService.getMilestone(milestoneId); + if (milestone.getStatus().equals(CLOSED)) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Milestone is closed"); + return ticketRepository.save(new Ticket( + ticketDto.title(), + ticketDto.description(), + milestone + )); + } + + @Transactional + public void assignTicketToUser(Long ticketId, String username, String requesterUsername) { + var ticket = ticketRepository.findById(ticketId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "ticket not found")); + if (!ticket.getMilestone().getProject().getTeamleader().getUsername().equals(requesterUsername)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only team leader can assign the ticket"); + var user = userService.getByUsername(username); + ticket.setAssignedUser(user); + ticketRepository.save(ticket); + } + + @Transactional + public void changeStatus(Long ticketId, TicketStatus status) { + var ticket = ticketRepository.findById(ticketId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "ticket not found")); + + ticket.setStatus(status); + ticketRepository.save(ticket); + } + + public TicketStatus getStatus(Long ticketId) { + return ticketRepository.findById(ticketId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "ticket not found")) + .getStatus(); + } +} diff --git a/src/main/java/com/example/services/UserService.java b/src/main/java/com/example/services/UserService.java new file mode 100644 index 0000000..e165e34 --- /dev/null +++ b/src/main/java/com/example/services/UserService.java @@ -0,0 +1,52 @@ +package com.example.services; + +import com.example.model.User; +import com.example.repo.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository repository; + private final UserRepository userRepository; + + public User save(User user) { + return repository.save(user); + } + + public User create(User user) { + if (repository.existsByUsername(user.getUsername())) { + // Заменить на свои исключения + throw new RuntimeException("Пользователь с таким именем уже существует"); + } + + if (repository.existsByEmail(user.getEmail())) { + throw new RuntimeException("Пользователь с таким email уже существует"); + } + + return save(user); + } + + public User getByUsername(String username) { + return repository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Пользователь не найден")); + + } + + public UserDetailsService userDetailsService() { + return this::getByUsername; + } + + public User getCurrentUser() { + var username = SecurityContextHolder.getContext().getAuthentication().getName(); + return getByUsername(username); + } + + public User getUserById(Long userId) { + return userRepository.getReferenceById(userId); + } +} 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/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..45c2af8 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +server: + servlet: + contextPath: /api/v1 +spring: + threads: + virtual: + enabled: true + application: + name: explore-modern-java-lab-2 + datasource: + url: jdbc:postgresql://localhost:5432/postgres + driverClassName: org.postgresql.Driver + username: postgres + password: 12345 + jpa: + hibernate: + ddl-auto: create-drop + +token: + signing: + key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA diff --git a/src/test/java/com/example/v1/ProjectManagementSystemTest.java b/src/test/java/com/example/v1/ProjectManagementSystemTest.java new file mode 100644 index 0000000..9cfbdeb --- /dev/null +++ b/src/test/java/com/example/v1/ProjectManagementSystemTest.java @@ -0,0 +1,432 @@ +package com.example.v1; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.*; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.junit.jupiter.api.Assertions.*; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class ProjectManagementSystemTest { + + private static final String BASE_URL = "http://localhost:8080/api/v1"; + + private static HttpClient client; + private static String managerToken; + private static String developerToken; + private static String testerToken; + private static String teamLeaderToken; + private static String otherUserToken; + + private static String projectId; + private static String milestoneId; + private static String ticketId; + private static String bugReportId; + + @BeforeAll + public static void setup() throws Exception { + client = HttpClient.newHttpClient(); + + // Регистрация и авторизация пользователей + managerToken = registerAndLogin("managerUser", "password"); + developerToken = registerAndLogin("developerUser", "password"); + testerToken = registerAndLogin("testerUser", "password"); + teamLeaderToken = registerAndLogin("teamLeaderUser", "password"); + otherUserToken = registerAndLogin("otherUser", "password"); + } + + @Test + @Order(1) + public void testCreateProject() throws Exception { + // Создание проекта менеджером + projectId = createProject(managerToken, "New Project", "Project Description"); + assertNotNull(projectId, "Project ID should not be null"); + } + + @Test + @Order(2) + public void testAssignTeamLeader() throws Exception { + // Назначение тимлидера + boolean success = assignTeamLeader(managerToken, projectId, "teamLeaderUser"); + assertTrue(success, "Team leader should be assigned successfully"); + } + + @Test + @Order(3) + public void testAddDeveloperAndTester() throws Exception { + // Добавление разработчика и тестировщика к проекту + boolean devAdded = addDeveloper(managerToken, projectId, "developerUser"); + boolean testerAdded = addTester(managerToken, projectId, "testerUser"); + assertTrue(devAdded, "Developer should be added successfully"); + assertTrue(testerAdded, "Tester should be added successfully"); + } + + @Test + @Order(4) + public void testCreateMilestone() throws Exception { + // Создание майлстоуна + milestoneId = createMilestoneWithCheckSuccess(managerToken, projectId, "Milestone 1", "2024-01-01", "2024-02-01"); + assertNotNull(milestoneId, "Milestone ID should not be null"); + } + + @Test + @Order(5) + public void testCreateAndAssignTicket() throws Exception { + // Тимлидер создает тикет и назначает разработчика + ticketId = checkSuccessCreateTicket(teamLeaderToken, milestoneId, "Implement feature X", "Details about feature X"); + assertNotNull(ticketId, "Ticket ID should not be null"); + + boolean assigned = assignTicket(teamLeaderToken, ticketId, "developerUser"); + assertTrue(assigned, "Ticket should be assigned successfully"); + } + + @Test + @Order(6) + public void testUpdateTicketStatus() throws Exception { + // Разработчик обновляет статус тикета + boolean statusUpdated1 = updateTicketStatus(developerToken, ticketId, "in_progress"); + boolean statusUpdated2 = updateTicketStatus(developerToken, ticketId, "completed"); + assertTrue(statusUpdated1, "Ticket status should be updated to in_progress"); + assertTrue(statusUpdated2, "Ticket status should be updated to completed"); + } + + @Test + @Order(7) + public void testCheckTicketCompletion() throws Exception { + // Менеджер проверяет выполнение тикета + String ticketStatus = getTicketStatus(managerToken, ticketId); + assertEquals("completed", ticketStatus, "Ticket status should be 'completed'"); + } + + @Test + @Order(8) + public void testCreateBugReport() throws Exception { + // Тестировщик тестирует проект и создает сообщение об ошибке + boolean tested = checkSuccessTestProject(testerToken, projectId); + assertTrue(tested, "Project should be tested successfully"); + + bugReportId = createBugReport(testerToken, projectId, "Found a bug in feature X"); + assertNotNull(bugReportId, "Bug report ID should not be null"); + } + + @Test + @Order(9) + public void testFixBugReport() throws Exception { + // Разработчик исправляет ошибку + boolean statusUpdated = updateBugReportStatus(developerToken, bugReportId, "fixed"); + assertTrue(statusUpdated, "Bug report status should be updated to 'fixed'"); + } + + @Test + @Order(10) + public void testVerifyBugFix() throws Exception { + // Тестировщик проверяет исправление + boolean tested = updateBugReportStatus(testerToken, bugReportId, "tested"); + boolean closed = updateBugReportStatus(testerToken, bugReportId, "closed"); + assertTrue(tested, "Bug report status should be updated to 'tested'"); + assertTrue(closed, "Bug report status should be updated to 'closed'"); + } + + @Test + @Order(11) + public void testCloseMilestone() throws Exception { + // Менеджер закрывает майлстоун + boolean statusUpdated = updateMilestoneStatus(managerToken, milestoneId, "closed"); + assertTrue(statusUpdated, "Milestone status should be updated to 'closed'"); + } + + @Test + @Order(12) + public void testDeveloperAssignTicket() throws Exception { + // Разработчик пытается назначить тикет другому разработчику + boolean assigned = assignTicket(developerToken, ticketId, "otherUser"); + assertFalse(assigned, "Developer should not be able to assign tickets"); + } + + @Test + @Order(13) + public void testTesterCreateMilestone() throws Exception { + // Тестировщик пытается создать майлстоун + HttpResponse response = sendCreateMilestoneRequest(testerToken, projectId, "Invalid Milestone", "2024-03-01", "2024-04-01"); + assertEquals(403, response.statusCode(), "Tester should not be able to create milestones"); + } + + @Test + @Order(14) + public void testAccessNonexistentProject() throws Exception { + // Тестировщик пытается получить доступ к несуществующему проекту + HttpResponse response = testProject(testerToken, "InvalidId"); + assertEquals(404, response.statusCode(), "Should receive 404 Not Found for nonexistent project"); + } + + @Test + @Order(15) + public void testAccessProjectUnauthorized() throws Exception { + HttpResponse response = testProject(otherUserToken, projectId); + assertEquals(403, response.statusCode(), "User should not have access to the project"); + } + + @Test + @Order(16) + public void testUpdateBugReportWithoutAccess() throws Exception { + // Пользователь пытается обновить сообщение об ошибке, к которому не имеет доступа + boolean statusUpdated = updateBugReportStatus(otherUserToken, bugReportId, "closed"); + assertFalse(statusUpdated, "User should not be able to update bug reports they do not have access to"); + } + + @Test + @Order(17) + public void testCreateTicketInClosedMilestone() throws Exception { + // Попытка создать тикет в закрытом майлстоуне + HttpResponse response = createTicket(teamLeaderToken, milestoneId, "New Task", "Details"); + assertEquals(403, response.statusCode(), "Should not be able to create ticket in a closed milestone"); + } + + @Test + @Order(18) + public void testManagerPerformDeveloperAction() throws Exception { + // Менеджер пытается выполнить действие разработчика (например, исправить баг) + boolean statusUpdated = updateBugReportStatus(managerToken, bugReportId, "fixed"); + assertFalse(statusUpdated, "Manager should not be able to perform developer actions"); + } + + private static String registerAndLogin(String username, String password) throws Exception { + JSONObject registerBody = new JSONObject() + .put("username", username) + .put("password", password) + .put("email", username + "@example.com"); + HttpRequest registerRequest = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/users/register")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(registerBody.toString())) + .build(); + HttpResponse registerResponse = client.send(registerRequest, HttpResponse.BodyHandlers.ofString()); + assertEquals(201, registerResponse.statusCode(), "User should be registered successfully"); + + JSONObject loginBody = new JSONObject() + .put("username", username) + .put("password", password); + HttpRequest loginRequest = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/users/login")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(loginBody.toString())) + .build(); + HttpResponse loginResponse = client.send(loginRequest, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, loginResponse.statusCode(), "User should be logged in successfully"); + + JSONObject loginJson = new JSONObject(loginResponse.body()); + return getValueOrNull(loginJson, "token"); + } + + private static String createProject(String token, String name, String description) throws Exception { + JSONObject body = new JSONObject() + .put("projectName", name) + .put("description", description); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(201, response.statusCode(), "Project should be created successfully"); + + JSONObject json = new JSONObject(response.body()); + return getValueOrNull(json, "projectId"); + } + + private static boolean assignTeamLeader(String token, String projectId, String username) throws Exception { + JSONObject body = new JSONObject() + .put("userId", username); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/teamleader")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static boolean addDeveloper(String token, String projectId, String username) throws Exception { + JSONObject body = new JSONObject() + .put("userId", username); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/developers")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static boolean addTester(String token, String projectId, String username) throws Exception { + JSONObject body = new JSONObject() + .put("userId", username); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/testers")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static String createMilestoneWithCheckSuccess(String token, String projectId, String name, String startDate, String endDate) throws Exception { + HttpResponse response = sendCreateMilestoneRequest(token, projectId, name, startDate, endDate); + assertEquals(201, response.statusCode(), "Milestone should be created successfully"); + + JSONObject json = new JSONObject(response.body()); + return getValueOrNull(json, "milestoneId"); + } + + private static HttpResponse sendCreateMilestoneRequest(String token, + String projectId, + String name, + String startDate, + String endDate) throws IOException, InterruptedException { + JSONObject body = new JSONObject() + .put("name", name) + .put("startDate", startDate) + .put("endDate", endDate); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/milestones")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private static String checkSuccessCreateTicket(String token, String milestoneId, String title, String description) throws Exception { + HttpResponse response = createTicket(token, milestoneId, title, description); + assertEquals(201, response.statusCode(), "Ticket should be created successfully"); + + JSONObject json = new JSONObject(response.body()); + return getValueOrNull(json, "ticketId"); + } + + private static HttpResponse createTicket(String token, String milestoneId, String title, String description) throws IOException, InterruptedException { + JSONObject body = new JSONObject() + .put("title", title) + .put("description", description); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/milestones/" + milestoneId + "/tickets")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private static boolean assignTicket(String token, String ticketId, String username) throws Exception { + JSONObject body = new JSONObject() + .put("userId", username); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/tickets/" + ticketId + "/assign")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static boolean updateTicketStatus(String token, String ticketId, String status) throws Exception { + JSONObject body = new JSONObject() + .put("status", status); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/tickets/" + ticketId + "/status")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .PUT(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static String getTicketStatus(String token, String ticketId) throws Exception { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/tickets/" + ticketId + "/status")) + .header("Authorization", "Bearer " + token) + .GET() + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(200, response.statusCode(), "Should retrieve ticket status successfully"); + + JSONObject json = new JSONObject(response.body()); + return getValueOrNull(json, "status"); + } + + private static boolean checkSuccessTestProject(String token, String projectId) throws Exception { + HttpResponse response = testProject(token, projectId); + return response.statusCode() == 200; + } + + private static HttpResponse testProject(String token, String projectId) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/test")) + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + private static String createBugReport(String token, String projectId, String description) throws Exception { + JSONObject body = new JSONObject() + .put("description", description); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/projects/" + projectId + "/bugreports")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertEquals(201, response.statusCode(), "Bug report should be created successfully"); + + JSONObject json = new JSONObject(response.body()); + return getValueOrNull(json, "bugReportId"); + } + + private static boolean updateBugReportStatus(String token, String bugReportId, String status) throws Exception { + JSONObject body = new JSONObject() + .put("status", status); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/bugreports/" + bugReportId + "/status")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .PUT(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static boolean updateMilestoneStatus(String token, String milestoneId, String status) throws Exception { + JSONObject body = new JSONObject() + .put("status", status); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/milestones/" + milestoneId + "/status")) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .PUT(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } + + private static String getValueOrNull(JSONObject json, String key) { + try { + return json.getString(key); + } catch (JSONException ignored) { + return null; + } + } +}