diff --git a/README.md b/README.md index ac72b89..38a008b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,177 @@ -# spring-onederful -기업용 Task 관리 시스템입니다. +# 💫 아웃소싱 API + +
+ +## 🎀 프로젝트 소개 + +
+ +아웃소싱 형태로 진행된 백엔드 프로젝트입니다. + +클라이언트 측에서 프론트엔드 개발을 완료한 상태에 참여하였으며, + +REST API 기반으로 프론트엔드와 안정적으로 연동되는 백엔드 서버를 설계 및 구축하였습니다. + +요구사항 분석부터 API 설계, 예외처리까지 전반적인 서버 개발을 수행한 프로젝트입니다. + +> **내일배움캠프 1조** +> +> **개발기간 : 2025-06-13 ~ 2025-06-20** + +
+ +## 🧰 기술 스택 + +![Java17](https://img.shields.io/badge/Java17-red) +![Springboot3.5](https://img.shields.io/badge/Springboot3.5-yellow) +![JWT](https://img.shields.io/badge/JWT-orange) +![Spring Data JPA](https://img.shields.io/badge/Spring_Data_JPA-green) +![QueryDSL](https://img.shields.io/badge/QueryDSL-blue) +![MySQL](https://img.shields.io/badge/MySQL-purple) +![Swagger](https://img.shields.io/badge/Swagger-pink) + +
+ +## 🖼️ ERD + +![img.png](img.png) + +
+ +## 📜 API 명세서 + +API +명세서는 [OutSourcing Project API 문서](https://teamsparta.notion.site/API-2112dc3ef51480a9a21cf45c77d1e85f) +를 클릭해주세요 + +
+ +## 👥 Team Member + +- **이의현 (팀장)** + 테스크 도메인 전반 (테스크 생성, 수정 ,조회, 상태변경, 삭제) 담당 + GitHub: [leeuihyun](https://github.com/leeuihyun) + + +- **이동근 (팀원)** + 회원 도메인 전반 (회원가입, 로그인, 유저 조회, JWT 인증) 담당 + GitHub: [DG0702](https://github.com/dg0702) + + +- **김두하 (팀원)** + 댓글 도메인 전반 (댓글 생성, 수정, 조회(검색), 삭제) 담당 + GitHub: [doohaaa](https://github.com/doohaaa) + + +- **김민성 (팀원)** + 대시보드 도메인 전반 (통계 정보 제공, 내 테스크 요약) 담당 + GitHub: [urzn](https://github.com/urzn) + + +- **우새빛 (팀원)** + 활동로그 도메인 전반 (주요 활동 기록, 활동 로그 조회) 담당 + GitHub: [saevit](https://github.com/saevit) + +
+ +## ✨ 주요 기능 + +- **docker를 이용하여 프론트엔드와 연결** + + +- **프론트엔드, 백엔드의 연결 → 연동 검증 : 브라우저에서 실제 요청/응답 확인** + +
+ +## ✨운영 환경 변수 + +```json +DB_URL=jdbc:mysql: //localhost:3306/yourdb +DB_USERNAME=yourusername +DB_PASSWORD=youruserpassword +SECRET_KEY=your_jwt_secret_key +``` + +
+ +## ✨SQL 실행방법 + +- 데이터베이스 자동생성 X +- `onederful.sql` 을 본인 데이터베이스에서 실행시켜서 수동 적용합니다. +- `onederful.sql` 은 최상위 경로에 있습니다. + +
+ +## 🪄 트러블 슈팅 + +### USER 도메인 + +- **406 ERROR (회원가입, 로그인)** + + - **응답 할 때 OffsetDateTime 역직렬화 불가 → 의존성을 추가하여 해결** + + +- **JWT 예외처리** + + - **`@ControllerAdvice`를 이용한 JWT 예외를 원하는 응답 형태로 변경 → Controller 보다 먼저 실행 되어 수동으로 응답 형태 생성** + + +- **CORS** + + - **docker 이용하여 프론트엔드 연결 후 백엔드 로직 실행 → CORS 문제 발생하여 CORS 설정하여 해결** + +### TASK 도메인 + +- **DB 정규화 지키기 위한 과도한 설계** + + - **불필요하게 테이블의 복잡성 상승 → Enum을 사용하여 불필요한 분리를 줄이고 타입의 안정성 상승** + + +- **TestCode** + + - **테스트 코드 작성하여 Sturbing 후 실행하였지만 값을 반환하지 않음** + + - **반환값을 직접 지정하여 해결** + +### COMMNET 도메인 + +- **협업의 어려움** + + - **각 도메인 파트별로 개발 → 나중에 연관이 있는 부분을 공통적으로 통일해야하는 문제 발생** + + - **팀원과 소통하며 기본적인 틀에서 조금씩 수정하여 해결** + + +- **프론트엔드 API를 이용한 개발의 어려움** + + - **API 설계를 도메인 위주로 하였으나 프론트 엔드 API와 달라 기능이 구현되지 않음** + + - **프론트엔드 API에 맞추어 API를 일부 수정하여 해결** + +### DASHBOARD + +- **Priority task 정렬** + + - **Priority`(Enum)` high-medium-low로 정렬하기 위해 `Enum`에 정수 필드를 추가 방식과 `Map<>`을 사용하여 정렬하는 방식을 고민** + + - **`Map<>`을 사용하는것으로 결정 → 순서를 정렬하는 로직이 이 경우밖에 없어서 결정** + + - **추가적으로 순서를 정렬하는 로직이 있을 경우 `Enum`에 정수 필드를 추가하여 사용하는 방향으로 선택** + +### LOG + +- **AOP 도입 및 책임 분리의 어려움** + + - **로직 분리의 기준을 정하는데 어려움 → 공통 처리와 비즈니스 로직 사이의 경계가 모호** + + - **AOP → 요청 메서드, URL, 로그인 한 유저ID 공통 정보 추출** + + - **추출한 데이터를 서비스 계층으로 전달하여 가공 및 DB 저장을 담당하도록 구조 설계함** + + + + + + + + diff --git a/build.gradle b/build.gradle index b8e8a1e..d0d6ef1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,39 +1,66 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.0' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.0' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // JWT + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // bcrypt + implementation 'at.favre.lib:bcrypt:0.10.2' + + // OffsetDateTime 역직렬화 하기 위해 필요한 모듈 + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // 테스트용 인메모리 DB + testImplementation 'com.h2database:h2' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + + //QueryDsl 추가 + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } + +clean { + delete file('src/main/generated') +} \ No newline at end of file diff --git a/img.png b/img.png new file mode 100644 index 0000000..18e4781 Binary files /dev/null and b/img.png differ diff --git a/onederful.sql b/onederful.sql new file mode 100644 index 0000000..76bb0a9 --- /dev/null +++ b/onederful.sql @@ -0,0 +1,62 @@ +CREATE TABLE users +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 식별자', + username VARCHAR(100) NOT NULL UNIQUE COMMENT '아이디', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', + password VARCHAR(255) NOT NULL COMMENT '비밀번호', + name VARCHAR(100) NOT NULL COMMENT '이름', + role VARCHAR(50) NOT NULL COMMENT '권한 (ENUM)', + created_at DATETIME COMMENT '생성일자', + updated_at DATETIME COMMENT '수정일자', + deleted_at DATETIME COMMENT '삭제날짜', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제여부' +); + +CREATE TABLE tasks +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '업무 식별자', + title VARCHAR(100) NOT NULL COMMENT '제목', + description TEXT NOT NULL COMMENT '설명', + priority VARCHAR(100) NOT NULL COMMENT '우선순위 식별자 (ENUM)', + assignee_id BIGINT NOT NULL COMMENT '담당자 식별자 (FK)', + user_id BIGINT NOT NULL COMMENT '작성자 식별자 (FK)', + status VARCHAR(100) NOT NULL COMMENT '태스크 상태 (ENUM)', + due_date DATETIME COMMENT '마감일자', + started_at DATETIME COMMENT '시작일자', + created_at DATETIME COMMENT '생성일자', + updated_at DATETIME COMMENT '수정일자', + deleted_at DATETIME COMMENT '삭제날짜', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제여부', + + FOREIGN KEY (assignee_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE TABLE comments +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 식별자', + content TEXT NOT NULL COMMENT '댓글 내용', + task_id BIGINT NOT NULL COMMENT '업무 식별자 (FK)', + user_id BIGINT NOT NULL COMMENT '작성자 식별자 (FK)', + created_at DATETIME COMMENT '생성일자', + updated_at DATETIME COMMENT '수정일자', + deleted_at DATETIME COMMENT '삭제날짜', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제여부', + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (task_id) REFERENCES tasks (id) ON DELETE CASCADE +); + +CREATE TABLE logs +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '로그 식별자', + user_id BIGINT NOT NULL COMMENT '사용자 식별자 (FK)', + activity VARCHAR(100) NOT NULL COMMENT '활동 유형', + ip_address VARCHAR(100) NOT NULL COMMENT 'ip 주소', + method VARCHAR(100) NOT NULL COMMENT '요청 메서드 (ENUM)', + target_id BIGINT NOT NULL COMMENT '작업 대상 식별자', + request_url VARCHAR(200) NOT NULL COMMENT '로그 요청 url', + created_at DATETIME COMMENT '생성일자', + + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); diff --git a/src/main/java/com/example/onederful/OnederfulApplication.java b/src/main/java/com/example/onederful/OnederfulApplication.java index 19d88ac..ae0bc15 100644 --- a/src/main/java/com/example/onederful/OnederfulApplication.java +++ b/src/main/java/com/example/onederful/OnederfulApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class OnederfulApplication { diff --git a/src/main/java/com/example/onederful/common/ApiResponseDto.java b/src/main/java/com/example/onederful/common/ApiResponseDto.java new file mode 100644 index 0000000..9189271 --- /dev/null +++ b/src/main/java/com/example/onederful/common/ApiResponseDto.java @@ -0,0 +1,27 @@ +package com.example.onederful.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.OffsetDateTime; + +@Getter +@AllArgsConstructor +public class ApiResponseDto { + + private boolean success; + private String message; + private Object data; + private OffsetDateTime timestamp; + + // 성공 응답 static 메서드 + public static ApiResponseDto success(String message,Object date){ + return new ApiResponseDto(true, message , date, OffsetDateTime.now()); + } + + // 실패 응답 static 메서드 + public static ApiResponseDto error(String message){ + return new ApiResponseDto(false, message, null, OffsetDateTime.now()); + } + +} diff --git a/src/main/java/com/example/onederful/common/ListResponse.java b/src/main/java/com/example/onederful/common/ListResponse.java new file mode 100644 index 0000000..445eb08 --- /dev/null +++ b/src/main/java/com/example/onederful/common/ListResponse.java @@ -0,0 +1,16 @@ +package com.example.onederful.common; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ListResponse { + + private List content; + private Long totalElements; + private int totalPages; + private int size; + private int number; +} diff --git a/src/main/java/com/example/onederful/config/PasswordEncoder.java b/src/main/java/com/example/onederful/config/PasswordEncoder.java new file mode 100644 index 0000000..f86d150 --- /dev/null +++ b/src/main/java/com/example/onederful/config/PasswordEncoder.java @@ -0,0 +1,17 @@ +package com.example.onederful.config; + +import at.favre.lib.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Component; + +@Component +public class PasswordEncoder { + + public String encode(String rawPassword){ + return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST,rawPassword.toCharArray()); + } + + public boolean matches(String rawPassword, String encodedPassword){ + BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(),encodedPassword); + return result.verified; + } +} diff --git a/src/main/java/com/example/onederful/config/SwaggerConfig.java b/src/main/java/com/example/onederful/config/SwaggerConfig.java new file mode 100644 index 0000000..08575de --- /dev/null +++ b/src/main/java/com/example/onederful/config/SwaggerConfig.java @@ -0,0 +1,27 @@ +package com.example.onederful.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + SecurityScheme bearerAuth = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList("BearerAuth"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("BearerAuth", bearerAuth)) + .addSecurityItem(securityRequirement); + } +} diff --git a/src/main/java/com/example/onederful/config/WebConfig.java b/src/main/java/com/example/onederful/config/WebConfig.java new file mode 100644 index 0000000..bb8ddf8 --- /dev/null +++ b/src/main/java/com/example/onederful/config/WebConfig.java @@ -0,0 +1,66 @@ +package com.example.onederful.config; + +import com.example.onederful.filter.JwtFilter; +import com.example.onederful.security.JwtUtil; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +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.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Bean + public FilterRegistrationBean corsFilter(){ + + CorsConfiguration config = new CorsConfiguration(); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + // 도메인 설정 + config.setAllowedOriginPatterns(List.of( + "http://localhost:3100" + )); + + // HTTP 메서드 설정 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + + // 헤더 설정 + config.setAllowedHeaders(List.of("*")); + + // 응답 헤더 + config.setExposedHeaders(List.of( + "Authorization", + "Content-Type" + )); + + // 인증 정보 허용 + config.setAllowCredentials(true); + + // 모든 경로에 설정 적용 + source.registerCorsConfiguration("/**", config); + + FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source)); + + bean.setOrder(0); + + return bean; + } + + @Bean + public FilterRegistrationBean jwtFilter(JwtUtil jwtUtil){ + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + + registrationBean.setFilter(new JwtFilter(jwtUtil)); + registrationBean.setOrder(1); + registrationBean.addUrlPatterns("/*"); + + return registrationBean; + } + + +} diff --git a/src/main/java/com/example/onederful/domain/comment/controller/CommentController.java b/src/main/java/com/example/onederful/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..c2551f4 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/controller/CommentController.java @@ -0,0 +1,105 @@ +package com.example.onederful.domain.comment.controller; + +import com.example.onederful.common.ApiResponseDto; +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.comment.dto.CommentRequestDto; +import com.example.onederful.domain.comment.dto.CommentResponseDataDto; +import com.example.onederful.domain.comment.service.CommentService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + // 댓글 생성 + @PostMapping("/tasks/{task_id}/comments") + public ResponseEntity save(@PathVariable Long task_id, + HttpServletRequest httpServletRequest, @RequestBody CommentRequestDto requestDto) { + + CommentResponseDataDto CommentResponseDataDto = + commentService.save(task_id, httpServletRequest, requestDto.getContent()); + + ApiResponseDto success = ApiResponseDto.success("댓글이 생성되었습니다,", CommentResponseDataDto); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 댓글 수정 + @PutMapping("/tasks/{task_id}/comments/{comment_id}") + public ResponseEntity updateComment( + @PathVariable Long task_id, @PathVariable Long comment_id, + @RequestBody CommentRequestDto requestDto, HttpServletRequest httpServletRequest + ) { + + CommentResponseDataDto CommentResponseDataDto = + commentService.updateComment(task_id, comment_id, requestDto.getContent(), + httpServletRequest); + + ApiResponseDto success = ApiResponseDto.success("댓글이 수정되었습니다.", CommentResponseDataDto); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 댓글 조회 (테스크별) + @GetMapping("/tasks/{task_id}/comments") + public ResponseEntity findAllCommentByTaskIdInPage( + @PathVariable Long task_id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + ListResponse commentResponseDtoInPage = commentService.findAllCommentByTaskIdInPage( + task_id, pageable); + + ApiResponseDto success = ApiResponseDto.success("댓글 목록을 조회했습니다.", commentResponseDtoInPage); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 댓글 조회 (내용 검색) + @GetMapping("/search") + public ResponseEntity findCommentByContent( + @RequestBody CommentRequestDto requestDto) { + + List commentResponseDataDtoList = commentService.findCommentByContent( + requestDto.getContent()); + + ApiResponseDto success = ApiResponseDto.success(requestDto.getContent() + "가 포함된 댓글 목록 ", + commentResponseDataDtoList); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + + // 댓글 삭제 + @DeleteMapping("/comments/{comment_id}") + public ResponseEntity deleteComment( + @PathVariable Long comment_id) { + + commentService.deleteComment(comment_id); + + ApiResponseDto success = ApiResponseDto.success("댓글이 삭제되었습니다.", null); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + +} diff --git a/src/main/java/com/example/onederful/domain/comment/dto/CommentRequestDto.java b/src/main/java/com/example/onederful/domain/comment/dto/CommentRequestDto.java new file mode 100644 index 0000000..d6c91aa --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/dto/CommentRequestDto.java @@ -0,0 +1,17 @@ +package com.example.onederful.domain.comment.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class CommentRequestDto { + + @Size(min=1, max = 500, message = "댓글 내용은 1~500자로 입력해주세요.") + @NotNull(message = "댓글 내용은 필수 항목입니다.") + private final String content; + + public CommentRequestDto(String content){ + this.content = content; + } +} diff --git a/src/main/java/com/example/onederful/domain/comment/dto/CommentResponseDataDto.java b/src/main/java/com/example/onederful/domain/comment/dto/CommentResponseDataDto.java new file mode 100644 index 0000000..5800071 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/dto/CommentResponseDataDto.java @@ -0,0 +1,46 @@ +package com.example.onederful.domain.comment.dto; + +import com.example.onederful.domain.comment.entity.Comment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Builder +@AllArgsConstructor +@Getter +public class CommentResponseDataDto { + private final Long id; + private final String content; + private final Long taskId; + private final Long userId; + private final UserData user; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public static CommentResponseDataDto of(Comment comment){ + return CommentResponseDataDto.builder() + .id(comment.getId()) + .content(comment.getContent()) + .taskId(comment.getTask().getId()) + .userId(comment.getUser().getId()) + .user(UserData.of(comment.getUser())) + .createdAt(comment.getCreatedAt()) + .updatedAt(comment.getUpdatedAt()) + .build(); + } + + + public static CommentResponseDataDto from(Comment comment){ + return new CommentResponseDataDto( + comment.getId(), + comment.getContent(), + comment.getTask().getId(), + comment.getUser().getId(), + UserData.of(comment.getUser()), + comment.getCreatedAt(), + comment.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/example/onederful/domain/comment/dto/UserData.java b/src/main/java/com/example/onederful/domain/comment/dto/UserData.java new file mode 100644 index 0000000..8968bcf --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/dto/UserData.java @@ -0,0 +1,27 @@ +package com.example.onederful.domain.comment.dto; + + +import com.example.onederful.domain.user.entity.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class UserData { + + private Long id; + private String username; + private String name; + private String email; + + public static UserData of(User user) { + return UserData.builder() + .id(user.getId()) + .username(user.getUsername()) + .name(user.getName()) + .email(user.getEmail()) + .build(); + } +} diff --git a/src/main/java/com/example/onederful/domain/comment/entity/Comment.java b/src/main/java/com/example/onederful/domain/comment/entity/Comment.java new file mode 100644 index 0000000..4ff00a4 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/entity/Comment.java @@ -0,0 +1,86 @@ +package com.example.onederful.domain.comment.entity; + +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comments; +import org.springframework.cglib.core.Local; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Builder +@Getter +@Table(name = "comments") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name= "task_id", nullable = false) + private Task task; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name= "user_id", nullable = false) + private User user; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder.Default + @Column(name="is_deleted", nullable = false) + private Boolean isDeleted = false; + + public Comment(String content, User user, Task task){ + this.user = user; + this.content = content; + this.task = task; + this.createdAt = LocalDateTime.now(); + this.isDeleted = false; + } + + public void update(String content){ + this.content = content; + this.updatedAt = LocalDateTime.now(); + this.isDeleted = false; + } + + public void delete(){ + this.isDeleted = true; + this.deletedAt= LocalDateTime.now(); + } + +} diff --git a/src/main/java/com/example/onederful/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/onederful/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..d770be1 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/repository/CommentRepository.java @@ -0,0 +1,19 @@ +package com.example.onederful.domain.comment.repository; + +import com.example.onederful.domain.comment.entity.Comment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + + List findAllByTaskIdOrderByCreatedAtDesc(Long taskId); + Page findByTaskIdAndIsDeletedFalse(Long task_id, Pageable pageable); + + @Query("SELECT c from Comment c WHERE(c.content LIKE %:keyword% OR c.user.username LIKE %:keyword%) AND c.isDeleted = false") + List findByContentOrUsername(@Param("keyword") String keyword); +} diff --git a/src/main/java/com/example/onederful/domain/comment/service/CommentService.java b/src/main/java/com/example/onederful/domain/comment/service/CommentService.java new file mode 100644 index 0000000..b88b1e9 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/comment/service/CommentService.java @@ -0,0 +1,115 @@ +package com.example.onederful.domain.comment.service; + +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.comment.dto.CommentResponseDataDto; +import com.example.onederful.domain.comment.entity.Comment; +import com.example.onederful.domain.comment.repository.CommentRepository; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.repository.TaskRepository; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final TaskRepository taskRepository; + private final JwtUtil jwtUtil; + + // 댓글 생성 + public CommentResponseDataDto save(Long task_id, HttpServletRequest httpServletRequest, + String content) { + + // 토큰에서 Id 가져오기 + Long user_id = jwtUtil.extractId(httpServletRequest); + + User user = userRepository.findById(user_id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + + Task task = taskRepository.findById(task_id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + + Comment comment = new Comment(content, user, task); + + Comment savedComment = commentRepository.save(comment); + return CommentResponseDataDto.of(savedComment); + + } + + // 댓글 수정 + @Transactional + public CommentResponseDataDto updateComment(Long task_id, Long comment_id, String content, + HttpServletRequest httpServletRequest) { + Task task = taskRepository.findById(task_id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + + Comment comment = commentRepository.findById(comment_id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_COMMENT)); + + // 토큰에서 Id 가져오기 + Long userId = jwtUtil.extractId(httpServletRequest); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + + if (comment.getIsDeleted()) { + throw new CustomException(ErrorCode.INVALID_COMMENT); + } + + comment.update(content); + return CommentResponseDataDto.of(comment); + } + + // 댓글 조회 (테스크별) + public ListResponse findAllCommentByTaskIdInPage(Long task_id, + Pageable pageable) { + // 페이징 대상 조회 + final Page commentListByIdInPage = commentRepository.findByTaskIdAndIsDeletedFalse( + task_id, pageable); + + return ListResponse.builder() + .content(commentListByIdInPage.getContent().stream().map(CommentResponseDataDto::of) + .collect(Collectors.toList())) + .size(commentListByIdInPage.getSize()) + .number(commentListByIdInPage.getNumber()) + .totalElements(commentListByIdInPage.getTotalElements()) + .totalPages(commentListByIdInPage.getTotalPages()) + .build(); + } + + // 댓글 조회 (내용 검색) + public List findCommentByContent(String content) { + + // 찾는 내용을 댓글을 적은 사람과 댓글 내용에서 검색 + List commentListByContent = commentRepository.findByContentOrUsername( + "%" + content + "%"); + + return commentListByContent.stream() + .filter(comment -> !comment.getIsDeleted()) + .map(CommentResponseDataDto::from) + .collect(Collectors.toList()); + } + + + // 댓글 삭제 + @Transactional + public void deleteComment(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_COMMENT)); + + comment.delete(); + } +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/controller/DashboardController.java b/src/main/java/com/example/onederful/domain/dashboard/controller/DashboardController.java new file mode 100644 index 0000000..1f1fccb --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/controller/DashboardController.java @@ -0,0 +1,55 @@ +package com.example.onederful.domain.dashboard.controller; + +import com.example.onederful.common.ApiResponseDto; +import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto; +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.dashboard.service.DashboardService; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final JwtUtil jwtUtil; + + @GetMapping("/statistics") + public ResponseEntity getStatistics(){ + StatisticsResponseDto data = dashboardService.getStatistics(); + + return new ResponseEntity<>( + ApiResponseDto.success( + "통계정보를 조회했습니다.", + data + ), + HttpStatus.OK + ); + } + + @GetMapping("/my-tasks-today") + public ResponseEntity getMyTasksToday( + HttpServletRequest request + ){ + Long userId = jwtUtil.extractId(request); + + List data = dashboardService.getMyTasksToday(userId); + + return new ResponseEntity<>( + ApiResponseDto.success( + "오늘 내 태스크 정보를 조회했습니다.", + data + ), + HttpStatus.OK + ); + } +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/dto/MyTasksTodayResponseDto.java b/src/main/java/com/example/onederful/domain/dashboard/dto/MyTasksTodayResponseDto.java new file mode 100644 index 0000000..bec39bc --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/dto/MyTasksTodayResponseDto.java @@ -0,0 +1,38 @@ +package com.example.onederful.domain.dashboard.dto; + +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.querydsl.core.annotations.QueryProjection; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class MyTasksTodayResponseDto { + + private final Long id; + + private final String title; + + private final String contents; + + private final Priority priority; + + private final Long managerId; + + private final Long userId; + + private final LocalDateTime deadline; + + private final ProcessStatus status; + + private final LocalDateTime started_at; + + private final LocalDateTime created_at; + + private final LocalDateTime updated_at; + +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/dto/StatisticsResponseDto.java b/src/main/java/com/example/onederful/domain/dashboard/dto/StatisticsResponseDto.java new file mode 100644 index 0000000..a72efe1 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/dto/StatisticsResponseDto.java @@ -0,0 +1,22 @@ +package com.example.onederful.domain.dashboard.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StatisticsResponseDto { + + private final Long totalTaskCount; + + private final Long todoTaskCount; + + private final Long inProgressTaskCount; + + private final Long doneTaskCount; + + private final Double taskDoneRate; + + private final Long overdueTaskCount; + +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepository.java b/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepository.java new file mode 100644 index 0000000..d89c9b6 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepository.java @@ -0,0 +1,12 @@ +package com.example.onederful.domain.dashboard.repository; + +import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto; +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.task.entity.Task; + +import java.util.List; + +public interface DashboardRepository { + StatisticsResponseDto getStatistics(); + List getMyTasksToday(Long userId); +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryImpl.java b/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryImpl.java new file mode 100644 index 0000000..3e50d22 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryImpl.java @@ -0,0 +1,129 @@ +package com.example.onederful.domain.dashboard.repository; + +import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto; +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.task.entity.QTask; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + + +@Repository +public class DashboardRepositoryImpl implements DashboardRepository { + + private final JPAQueryFactory queryFactory; + + public DashboardRepositoryImpl(EntityManager em){ + this.queryFactory = new JPAQueryFactory(em); + } + + QTask task; + + @Override + public StatisticsResponseDto getStatistics(){ + + task = QTask.task; + + Long total = Optional.ofNullable( + queryFactory + .select(task.count()) + .from(task) + .where(task.isDeleted.isFalse()) + .fetchOne() + ).orElse(0L); + + Long todo = Optional.ofNullable( + queryFactory + .select(task.count()) + .from(task) + .where( + task.status.eq(ProcessStatus.TODO), + task.isDeleted.isFalse() + ) + .fetchOne() + ).orElse(0L);; + + Long inProgress = Optional.ofNullable( + queryFactory + .select(task.count()) + .from(task) + .where( + task.status.eq(ProcessStatus.IN_PROGRESS), + task.isDeleted.isFalse() + ) + .fetchOne() + ).orElse(0L); + + Long done = Optional.ofNullable( + queryFactory + .select(task.count()) + .from(task) + .where( + task.status.eq(ProcessStatus.DONE), + task.isDeleted.isFalse() + ) + .fetchOne() + ).orElse(0L); + + Long overdue = Optional.ofNullable( + queryFactory + .select(task.count()) + .from(task) + .where( + task.isDeleted.isFalse(), + task.status.in(ProcessStatus.TODO, ProcessStatus.IN_PROGRESS), + task.dueDate.before(LocalDateTime.now()) + ) + .fetchOne() + ).orElse(0L); + + double taskDoneRate = 0.0; + if(total != 0L){ + taskDoneRate = Math.round((double) done / total * 100 * 100) / 100.0; + + } + + return StatisticsResponseDto.builder() + .totalTaskCount(total) + .todoTaskCount(todo) + .inProgressTaskCount(inProgress) + .doneTaskCount(done) + .taskDoneRate(taskDoneRate) + .overdueTaskCount(overdue) + .build(); + } + + @Override + public List getMyTasksToday(Long userId){ + task = QTask.task; + Map priority = Map.of( + Priority.HIGH, 0, + Priority.MEDIUM, 1, + Priority.LOW, 2 + ); + + List taskList = queryFactory + .select(task) + .from(task) + .where( + task.assignee.id.eq(userId), + task.isDeleted.isFalse(), + task.status.in(ProcessStatus.TODO, ProcessStatus.IN_PROGRESS) + ) + .fetch(); + List sortedTaskList = taskList.stream() + .sorted(Comparator.comparing(task -> priority.get(task.getPriority()))) + .collect(Collectors.toList()); + + return sortedTaskList; + } +} diff --git a/src/main/java/com/example/onederful/domain/dashboard/service/DashboardService.java b/src/main/java/com/example/onederful/domain/dashboard/service/DashboardService.java new file mode 100644 index 0000000..ab1fa28 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/dashboard/service/DashboardService.java @@ -0,0 +1,51 @@ +package com.example.onederful.domain.dashboard.service; + +import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto; +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.dashboard.repository.DashboardRepository; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DashboardService { + + private final DashboardRepository dashboardRepository; + private final UserRepository userRepository; + + public StatisticsResponseDto getStatistics() { + + return dashboardRepository.getStatistics(); + } + + public List getMyTasksToday(Long userId) { + + userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + + List tasks = dashboardRepository.getMyTasksToday(userId); + List dtos = tasks.stream() + .map(t -> MyTasksTodayResponseDto.builder() + .id(t.getId()) + .title(t.getTitle()) + .contents(t.getDescription()) + .priority(t.getPriority()) + .managerId(t.getAssignee().getId()) + .userId(t.getUser().getId()) + .deadline(t.getDueDate()) + .status(t.getStatus()) + .started_at(t.getStartedAt()) + .created_at(t.getCreatedAt()) + .updated_at(t.getUpdatedAt()) + .build() + ) + .toList(); + + return dtos; + } +} diff --git a/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java b/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java new file mode 100644 index 0000000..8d6ce5c --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/HttpRequestUtil.java @@ -0,0 +1,63 @@ +package com.example.onederful.domain.log; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import com.example.onederful.domain.log.enums.Method; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import com.example.onederful.security.JwtUtil; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class HttpRequestUtil { + + private final JwtUtil jwtUtil; + + // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userId + public RequestInfo getRequestInfo() { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) { + throw new CustomException(ErrorCode.INVALID_OR_EXPIRED_REQUEST); + } + HttpServletRequest request = attrs.getRequest(); + if (request == null) { + throw new CustomException(ErrorCode.INVALID_OR_EXPIRED_REQUEST); + } + + // 요청한 사용자의 ip + String ip = request.getRemoteAddr(); + + // 요청 메서드 + String method = request.getMethod(); + Method enumMethod = Method.valueOf(method); + + // 요청 url + String url = request.getRequestURI(); + + // 토큰으로부터 요청한 사용자의 userId + Long userId = null; + // 로그인, 회원가입 등 토큰 체크 안 할 URL 처리 + if (!url.startsWith("/api/auth/login") && !url.startsWith("/api/auth/register")) { + userId = jwtUtil.extractId(request); + } + + return new RequestInfo(ip, enumMethod, url, userId); + } + + // 반환용 클래스 + @Getter + @AllArgsConstructor + public static class RequestInfo { + private final String ip; + private final Method method; + private final String url; + private final Long userId; + } +} diff --git a/src/main/java/com/example/onederful/domain/log/LoggingAspect.java b/src/main/java/com/example/onederful/domain/log/LoggingAspect.java new file mode 100644 index 0000000..ff92320 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/LoggingAspect.java @@ -0,0 +1,106 @@ +package com.example.onederful.domain.log; + +import com.example.onederful.domain.log.enums.Activity; +import com.example.onederful.domain.log.service.LogService; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.task.service.TaskService; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class LoggingAspect { + + private final HttpRequestUtil httpRequestUtil; + private final TaskService taskService; + private final LogService logService; + + @Pointcut("execution(* com.example..UserService.login(..))") + public void loginMethod() { + } + + @Pointcut( + "execution(* com.example..TaskService.createTask(..)) || " + + "execution(* com.example..TaskService.updateTask(..)) || " + + "execution(* com.example..TaskService.deleteTask(..)) || " + + "execution(* com.example..CommentService.save(..)) || " + + "execution(* com.example..CommentService.updateComment(..)) || " + + "execution(* com.example..CommentService.deleteComment(..))" + ) + public void cudMethods() { + } + + @Pointcut("execution(* com.example..TaskService.updateTaskStatus(..))") + public void updateTaskStatusMethod() { + } + + // 로그인 시 자동 로그 기록 + @AfterReturning(pointcut = "loginMethod()", returning = "result") + public void logLoginMethod(Object result) { + + // HttpServletRequest으로부터 요청 ip, 메서드, url + HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo(); + + // 로그 저장 + logService.saveLoginLog(request.getIp(), request.getMethod(), request.getUrl(), result); + } + + // 생성, 수정, 삭제 시 자동 로그 기록 + @AfterReturning(pointcut = "cudMethods()", returning = "result") + public void logCudMethods(Object result) { + + // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userid + HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo(); + + // 로그 저장 + logService.saveCudLog(request.getIp(), request.getMethod(), request.getUrl(), + request.getUserId(), result); + } + + // 상태 변경 시 자동 로그 기록 + @Around("updateTaskStatusMethod()") + public Object logTaskStatusChange(ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + Long taskId = (Long) args[0]; // 첫 번째 인자가 taskId + + // 기존 task 상태 조회 + Task beforeTask = taskService.findById(taskId); // 서비스 계층 사용 + ProcessStatus beforeStatus = beforeTask != null ? beforeTask.getStatus() : null; + + // 메서드 실행 + Object result = joinPoint.proceed(); + + // 변경 후 task 상태 조회 + Task afterTask = taskService.findById(taskId); + ProcessStatus afterStatus = afterTask != null ? afterTask.getStatus() : null; + + // 변경되었는지 비교 후 로그 기록 + Activity activity = null; + if (Objects.equals(beforeStatus, ProcessStatus.TODO) && Objects.equals(afterStatus, + ProcessStatus.IN_PROGRESS)) { + activity = Activity.TASK_STATUS_TODO_TO_IN_PROGRESS; + } else if (Objects.equals(beforeStatus, ProcessStatus.IN_PROGRESS) && Objects.equals( + afterStatus, ProcessStatus.DONE)) { + activity = Activity.TASK_STATUS_IN_PROGRESS_TO_DONE; + } + + if (activity != null) { + // HttpServletRequest으로부터 요청 ip, 메서드, url, 로그인한 userid + HttpRequestUtil.RequestInfo request = httpRequestUtil.getRequestInfo(); + + // 로그 저장 + logService.saveTaskStatusChangeLog(request.getIp(), request.getMethod(), + request.getUrl(), request.getUserId(), taskId, activity); + } + + return result; + } +} diff --git a/src/main/java/com/example/onederful/domain/log/controller/LogController.java b/src/main/java/com/example/onederful/domain/log/controller/LogController.java new file mode 100644 index 0000000..4ff49a3 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/controller/LogController.java @@ -0,0 +1,52 @@ +package com.example.onederful.domain.log.controller; + +import com.example.onederful.common.ApiResponseDto; +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.log.dto.LogResponse; +import com.example.onederful.domain.log.service.LogService; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LogController { + + private final LogService logService; + + /** + * 활동 로그 조회 + *

+ * 검색 조건: + * + * @param userId 유저 아이디 (필수 N) + * @param activity 활동 유형 (필수 N) + * @param targetId 대상 ID (필수 N) + * @param start 시작일 (필수 N) + * @param end 종료일 (필수 N) + * @param pageable 페이징을 위한 page, size, sort (필수 N) + * @return 조회된 활동 로그 + */ + @GetMapping("/api/activities") + public ResponseEntity getLog( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String activity, + @RequestParam(required = false) Long targetId, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate start, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end, + @PageableDefault(page = 0, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + + ListResponse response = logService.findLog(userId, activity, targetId, start, + end, pageable); + + return ResponseEntity.ok(ApiResponseDto.success("활동 로그 리스트 조회에 성공하였습니다.", response)); + } +} diff --git a/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java b/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java new file mode 100644 index 0000000..10ec211 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/dto/LogResponse.java @@ -0,0 +1,39 @@ +package com.example.onederful.domain.log.dto; + +import java.time.LocalDateTime; + +import com.example.onederful.domain.log.entity.Log; + +import lombok.Getter; + +@Getter +public class LogResponse { + LocalDateTime createdAt; + String userName; + String activityStr; + Long targetID; + String logMessage; + + public LogResponse( + LocalDateTime createdAt, + String userName, + String activityStr, + Long targetID, + String logMessage) + { + this.createdAt = createdAt; + this.userName = userName; + this.activityStr = activityStr; + this.targetID = targetID; + this.logMessage = logMessage; + } + + public static LogResponse of(Log log) { + return new LogResponse( + log.getCreatedAt(), + log.getUser().getName(), + log.getActivity().toString(), + log.getTargetId(), + log.getActivity().getLogMessage()); + } +} diff --git a/src/main/java/com/example/onederful/domain/log/entity/Log.java b/src/main/java/com/example/onederful/domain/log/entity/Log.java new file mode 100644 index 0000000..f01f6e1 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/entity/Log.java @@ -0,0 +1,64 @@ +package com.example.onederful.domain.log.entity; + +import com.example.onederful.domain.log.enums.Activity; +import com.example.onederful.domain.log.enums.Method; +import com.example.onederful.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Builder +@Getter +@Table(name = "logs") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Log { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name="user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name="activity", nullable = false) + private Activity activity; + + @Column(name="ip_address", nullable=false) + private String ipAddress; + + @Enumerated(EnumType.STRING) + @Column(name = "method", nullable = false) + private Method method; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name="request_url", nullable = false) + private String requestUrl; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/example/onederful/domain/log/enums/Activity.java b/src/main/java/com/example/onederful/domain/log/enums/Activity.java new file mode 100644 index 0000000..e6d0ad6 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/enums/Activity.java @@ -0,0 +1,26 @@ +package com.example.onederful.domain.log.enums; + +public enum Activity { + TASK_CREATED("새로운 작업이 생성되었습니다."), + TASK_UPDATED("작업이 수정되었습니다."), + TASK_DELETED("작업이 삭제되었습니다."), + TASK_STATUS_TODO_TO_IN_PROGRESS("작업이 TODO에서 IN_PROGRESS로 변경되었습니다."), + TASK_STATUS_IN_PROGRESS_TO_DONE("작업이 IN_PROGRESS에서 DONE으로 변경되었습니다."), + COMMENT_CREATED("새로운 댓글이 생성되었습니다."), + COMMENT_UPDATED("댓글이 수정되었습니다."), + COMMENT_DELETED("댓글이 삭제되었습니다."), + USER_LOGGED_IN("로그인 하였습니다."); + // USER_LOGGED_OUT("로그아웃 하였습니다.") + + private final String logMessage; + + // 생성자 + Activity(String logMessage) { + this.logMessage = logMessage; + } + + // getter + public String getLogMessage() { + return logMessage; + } +} diff --git a/src/main/java/com/example/onederful/domain/log/enums/Method.java b/src/main/java/com/example/onederful/domain/log/enums/Method.java new file mode 100644 index 0000000..98a00ca --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/enums/Method.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.log.enums; + +public enum Method { + POST, GET, PATCH, PUT, DELETE +} diff --git a/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java b/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java new file mode 100644 index 0000000..c124625 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/repository/LogRepository.java @@ -0,0 +1,11 @@ +package com.example.onederful.domain.log.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import com.example.onederful.domain.log.entity.Log; + +@Repository +public interface LogRepository extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java b/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java new file mode 100644 index 0000000..a0c035c --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/repository/LogSpecification.java @@ -0,0 +1,46 @@ +package com.example.onederful.domain.log.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.domain.Specification; + +import com.example.onederful.domain.log.entity.Log; +import com.example.onederful.domain.log.enums.Activity; + +public class LogSpecification { + // userId를 통한 검색 조건 (WHERE userId = ?) + public static Specification hasUserId(Long userId) { + return (root, query, builder) -> + userId == null ? null : builder.equal(root.get("user").get("id"), userId); + } + + // activity를 통한 검색 조건 (WHERE activity = ?) + public static Specification hasActivity(Activity activity) { + return (root, query, builder) -> + activity == null ? null : builder.equal(root.get("activity"), activity); + } + + // targetId를 통한 검색 조건 (WHERE targetId = ?) + public static Specification hasTargetId(Long targetId) { + return (root, query, builder) -> + targetId == null ? null : builder.equal(root.get("targetId"), targetId); + } + + // 날짜를 통한 검색 조건 (WHERE ? BETWEEN start and end) + public static Specification betweenDates(LocalDate start, LocalDate end) { + return (root, query, builder) -> { + // 둘다 없을 경우 + if (start == null && end == null) return null; + // 둘다 있을 경우 -> between + // start.atStartOfDay() = 00-01-01(시작날) 00:00:00 + // end.plusDays(1).atStartOfDay() = (00-01-02(종료날)일 경우) 00-01-03 00:00:00 + if (start != null && end != null) + return builder.between(root.get("createdAt"), start.atStartOfDay(), end.plusDays(1).atStartOfDay()); + // 시작날만 있을 경우 + if (start != null) + return builder.greaterThanOrEqualTo(root.get("createdAt"), start.atStartOfDay()); + // 종료날만 있을 경우 + return builder.lessThan(root.get("createdAt"), end.plusDays(1).atStartOfDay()); + }; + } +} diff --git a/src/main/java/com/example/onederful/domain/log/service/LogService.java b/src/main/java/com/example/onederful/domain/log/service/LogService.java new file mode 100644 index 0000000..df2ce0b --- /dev/null +++ b/src/main/java/com/example/onederful/domain/log/service/LogService.java @@ -0,0 +1,175 @@ +package com.example.onederful.domain.log.service; + +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.comment.dto.CommentResponseDataDto; +import com.example.onederful.domain.log.dto.LogResponse; +import com.example.onederful.domain.log.entity.Log; +import com.example.onederful.domain.log.enums.Activity; +import com.example.onederful.domain.log.enums.Method; +import com.example.onederful.domain.log.repository.LogRepository; +import com.example.onederful.domain.log.repository.LogSpecification; +import com.example.onederful.domain.task.dto.response.TaskResponse; +import com.example.onederful.domain.user.dto.Tokeninfo; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import com.example.onederful.security.JwtUtil; +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogService { + + private final LogRepository logRepository; + private final UserRepository userRepositry; + private final JwtUtil jwtUtil; + + // log 조회 메서드 + public ListResponse findLog( + Long userId, String activityStr, Long targetId, + LocalDate start, LocalDate end, Pageable pageable) { + + // 활동 유형 Enum 형태로 변환 + Activity activity = null; + try { + if (activityStr != null) { + activity = Activity.valueOf(activityStr); + } + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.INVALID_ACTIVITY); + } + + // 들어온 조건 여부로 동적 쿼리 설정 + Specification spec = + LogSpecification.hasUserId(userId) + .and(LogSpecification.hasActivity(activity)) + .and(LogSpecification.hasTargetId(targetId)) + .and(LogSpecification.betweenDates(start, end)); + + Page logs = logRepository.findAll(spec, pageable); + + return ListResponse.builder() + .content(logs.getContent().stream().map(LogResponse::of).collect(Collectors.toList())) + .totalElements(logs.getTotalElements()) + .size(logs.getSize()) + .number(logs.getNumber()) + .totalPages(logs.getTotalPages()) + .build(); + } + + // 로그인 시 로그 기록 + @Transactional + public void saveLoginLog(String ip, Method method, String url, Object result) { + // userId + Long userId = null; + if (result instanceof Tokeninfo) { + String token = ((Tokeninfo) result).getToken(); + userId = jwtUtil.extractAllClaims(token).get("id", Long.class); + } + + // 현재 유저 조회 + User user = userRepositry.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.UNAUTHORIZED) + ); + + // 활동 유형 + Activity activity = Activity.USER_LOGGED_IN; + + // 대상 id + Long targetId = userId; + + // 로그 DB에 저장 + Log log = Log.builder() + .user(user) + .activity(activity) + .ipAddress(ip) + .method(method) + .targetId(targetId) + .requestUrl(url) + .build(); + + logRepository.save(log); + } + + // 생성, 수정, 삭제 시 로그 기록 + @Transactional + public void saveCudLog(String ip, Method method, String url, Long userId, Object result) { + // 현재 유저 조회 + User user = userRepositry.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.UNAUTHORIZED) + ); + + // 활동 유형 -> 요청 메서드와 url로 일치하는 활동 유형 찾기 + Activity activity = null; + if (method.equals(Method.POST) && url.contains("/comments")) { + activity = Activity.COMMENT_CREATED; + } else if (method.equals(Method.PUT) && url.contains("/comments")) { + activity = Activity.COMMENT_UPDATED; + } else if (method.equals(Method.DELETE) && url.contains("/comments")) { + activity = Activity.COMMENT_DELETED; + } else if (method.equals(Method.POST) && url.contains("/tasks")) { + activity = Activity.TASK_CREATED; + } else if (method.equals(Method.PUT) && url.contains("/tasks")) { + activity = Activity.TASK_UPDATED; + } else if (method.equals(Method.DELETE) && url.contains("/tasks")) { + activity = Activity.TASK_DELETED; + } + + // 대상 id -> 생성인 경우 응답에서 / 수정과 삭제의 경우 url 마지막에서 찾기 + Long targetId = null; + if (activity.equals(Activity.TASK_CREATED)) { + if (result instanceof TaskResponse) { + targetId = ((TaskResponse) result).getId(); + } + } else if (activity.equals(Activity.COMMENT_CREATED)) { + if (result instanceof CommentResponseDataDto) { + targetId = ((CommentResponseDataDto) result).getId(); + } + } else { + String[] parts = url.split("/"); + String lastPart = parts[parts.length - 1]; // /api/.../{id}의 id + targetId = Long.parseLong(lastPart); + } + + // 로그 DB에 저장 + Log log = Log.builder() + .user(user) + .activity(activity) + .ipAddress(ip) + .method(method) + .targetId(targetId) + .requestUrl(url) + .build(); + + logRepository.save(log); + } + + // 상태 변경 시 로그 기록 + public void saveTaskStatusChangeLog(String ip, Method method, String url, Long userId, + Long targetId, Activity activity) { + // 현재 유저 조회 + User user = userRepositry.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.UNAUTHORIZED) + ); + + // 로그 DB에 저장 + Log log = Log.builder() + .user(user) + .activity(activity) + .ipAddress(ip) + .method(method) + .targetId(targetId) + .requestUrl(url) + .build(); + + logRepository.save(log); + } +} diff --git a/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java b/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java new file mode 100644 index 0000000..e910e6d --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/common/CreateGroup.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.task.common; + +public interface CreateGroup { + +} diff --git a/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java b/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java new file mode 100644 index 0000000..452d884 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/common/UpdateGroup.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.task.common; + +public interface UpdateGroup { + +} diff --git a/src/main/java/com/example/onederful/domain/task/controller/TaskController.java b/src/main/java/com/example/onederful/domain/task/controller/TaskController.java new file mode 100644 index 0000000..2570a24 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/controller/TaskController.java @@ -0,0 +1,99 @@ +package com.example.onederful.domain.task.controller; + +import com.example.onederful.common.ApiResponseDto; +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.task.common.CreateGroup; +import com.example.onederful.domain.task.common.UpdateGroup; +import com.example.onederful.domain.task.dto.request.TaskSaveRequest; +import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest; +import com.example.onederful.domain.task.dto.response.TaskResponse; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.task.service.TaskService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Validated +@RequiredArgsConstructor +@RequestMapping("/api/tasks") +public class TaskController { + + private final TaskService taskService; + + @PostMapping + public ResponseEntity createTask( + @RequestBody @Validated(CreateGroup.class) @Valid TaskSaveRequest request, + HttpServletRequest httpServletRequest) { + + TaskResponse response = taskService.createTask(request, httpServletRequest); + + return ResponseEntity.ok(ApiResponseDto.success("업무 생성에 성공하였습니다.", response)); + } + + @GetMapping("/{id}") + public ResponseEntity findTask(@PathVariable @NotNull Long id) { + + TaskResponse response = taskService.findTask(id); + + return ResponseEntity.ok(ApiResponseDto.success("업무 상세조회에 성공하였습니다.", response)); + } + + @GetMapping + public ResponseEntity findTasks( + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "5") @Min(5) int size, + @RequestParam(defaultValue = "") String search, + @RequestParam(defaultValue = "TODO") ProcessStatus status + ) { + Pageable pageable = PageRequest.of(page, size, Direction.ASC, "dueDate"); + + ListResponse response = taskService.findTasks(pageable, search, status); + + return ResponseEntity.ok(ApiResponseDto.success("업무 리스트 조회에 성공하였습니다.", response)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTask(@PathVariable @NotNull Long id) { + + taskService.deleteTask(id); + + return ResponseEntity.ok(ApiResponseDto.success("업무 삭제에 성공하였습니다.", null)); + } + + @PutMapping("/{id}") + public ResponseEntity updateTask(@PathVariable @NotNull Long id, + @RequestBody @Validated(UpdateGroup.class) @Valid TaskSaveRequest request) { + + TaskResponse response = taskService.updateTask(id, request); + + return ResponseEntity.ok(ApiResponseDto.success("업무 수정에 성공하였습니다.", response)); + } + + @PatchMapping("/{id}/status") + public ResponseEntity updateTaskStatus(@PathVariable @NotNull Long id, + @RequestBody @Valid + TaskStatusUpdateRequest request) { + + TaskResponse response = taskService.updateTaskStatus(id, request); + + return ResponseEntity.ok(ApiResponseDto.success("업무 상태 변경에 성공하였습니다.", response)); + } +} diff --git a/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java b/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java new file mode 100644 index 0000000..bd2cd07 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/dto/request/TaskSaveRequest.java @@ -0,0 +1,37 @@ +package com.example.onederful.domain.task.dto.request; + +import com.example.onederful.domain.task.common.UpdateGroup; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TaskSaveRequest { + + @NotBlank(message = "업무 제목은 필수 항목입니다.") + @Size(min = 1, max = 100, message = "1자 이상 100자 이하로 입력해주세요.") + private String title; + + @NotBlank(message = "업무 내용은 필수 항목입니다.") + private String description; + + @NotNull(message = "업무의 우선순위는 필수 항목입니다.") + private Priority priority; + + @NotNull(message = "관리자 선택은 필수 항목입니다.") + private Long assigneeId; + + @NotNull(groups = {UpdateGroup.class}, message = "업무의 상태는 필수 항목입니다.") + private ProcessStatus status; + + @NotNull(message = "마감일은 필수 항목입니다.") + @FutureOrPresent(message = "마감일은 오늘이후만 가능합니다.") + private LocalDateTime dueDate; +} diff --git a/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java b/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java new file mode 100644 index 0000000..4668624 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/dto/request/TaskStatusUpdateRequest.java @@ -0,0 +1,14 @@ +package com.example.onederful.domain.task.dto.request; + +import com.example.onederful.domain.task.enums.ProcessStatus; +import lombok.Getter; + +@Getter +public class TaskStatusUpdateRequest { + + private final ProcessStatus status; + + public TaskStatusUpdateRequest(ProcessStatus status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java b/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java new file mode 100644 index 0000000..4b59157 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/dto/response/TaskAssignee.java @@ -0,0 +1,29 @@ +package com.example.onederful.domain.task.dto.response; + +import com.example.onederful.domain.user.entity.User; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TaskAssignee { + + private Long id; + private String username; + private String name; + private String email; + + public static TaskAssignee of(User user) { + return TaskAssignee.builder() + .id(user.getId()) + .username(user.getUsername()) + .name(user.getName()) + .email(user.getEmail()) + .build(); + } +} diff --git a/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java b/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java new file mode 100644 index 0000000..2b3b802 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/dto/response/TaskResponse.java @@ -0,0 +1,46 @@ +package com.example.onederful.domain.task.dto.response; + +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TaskResponse { + + private Long id; + private String title; + private String description; + private Priority priority; + private ProcessStatus status; + private Long assigneeId; + private TaskAssignee assignee; + + private OffsetDateTime dueDate; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public static TaskResponse of(Task task) { + return TaskResponse.builder() + .id(task.getId()) + .title(task.getTitle()) + .description(task.getDescription()) + .status(task.getStatus()) + .priority(task.getPriority()) + .assigneeId(task.getAssignee().getId()) + .assignee(TaskAssignee.of(task.getAssignee())) + .dueDate(task.getDueDate().atOffset(ZoneOffset.UTC)) + .createdAt(task.getCreatedAt().atOffset(ZoneOffset.UTC)) + .updatedAt(task.getUpdatedAt().atOffset(ZoneOffset.UTC)) + .build(); + } +} diff --git a/src/main/java/com/example/onederful/domain/task/entity/Task.java b/src/main/java/com/example/onederful/domain/task/entity/Task.java new file mode 100644 index 0000000..e4d51e8 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/entity/Task.java @@ -0,0 +1,109 @@ +package com.example.onederful.domain.task.entity; + + +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Builder +@Getter +@Table(name = "tasks") +@SQLRestriction("is_deleted = false") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Task { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "description", nullable = false) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "priority", nullable = false) + private Priority priority; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignee_id", nullable = false) + private User assignee; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private ProcessStatus status; + + @Column(name = "due_date", nullable = false) + private LocalDateTime dueDate; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder.Default + @Column(name = "is_deleted", nullable = false) + private Boolean isDeleted = false; + + public void updateTask(String title, String content, Priority priority, User assignee, + LocalDateTime dueDate, ProcessStatus status) { + this.title = title; + this.description = content; + this.priority = priority; + this.assignee = assignee; + this.dueDate = dueDate; + this.status = status; + } + + public void updateTaskStatus(ProcessStatus status) { + this.status = status; + } + + public void delete() { + this.isDeleted = true; + this.deletedAt = LocalDateTime.now(); + } + + public void taskStart() { + this.startedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/example/onederful/domain/task/enums/Priority.java b/src/main/java/com/example/onederful/domain/task/enums/Priority.java new file mode 100644 index 0000000..74f7633 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/enums/Priority.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.task.enums; + +public enum Priority { + LOW, MEDIUM, HIGH +} diff --git a/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java b/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java new file mode 100644 index 0000000..a828a6c --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/enums/ProcessStatus.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.task.enums; + +public enum ProcessStatus { + TODO,IN_PROGRESS,DONE +} diff --git a/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java b/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java new file mode 100644 index 0000000..c2bd7a8 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/repository/TaskRepository.java @@ -0,0 +1,18 @@ +package com.example.onederful.domain.task.repository; + +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.ProcessStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TaskRepository extends JpaRepository { + + @Query("SELECT t FROM Task t WHERE (t.title LIKE %:search% OR t.description LIKE %:search%) AND t.status = :status") + Page findTasks(@Param("search") String search, @Param("status") ProcessStatus status, + Pageable pageable); +} diff --git a/src/main/java/com/example/onederful/domain/task/service/TaskService.java b/src/main/java/com/example/onederful/domain/task/service/TaskService.java new file mode 100644 index 0000000..bc7bbd8 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/task/service/TaskService.java @@ -0,0 +1,144 @@ +package com.example.onederful.domain.task.service; + +import com.example.onederful.common.ListResponse; +import com.example.onederful.domain.task.dto.request.TaskSaveRequest; +import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest; +import com.example.onederful.domain.task.dto.response.TaskResponse; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.task.repository.TaskRepository; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TaskService { + + private final TaskRepository taskRepository; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + @Transactional + public TaskResponse createTask(TaskSaveRequest request, HttpServletRequest httpServletRequest) { + + Long userId = jwtUtil.extractId(httpServletRequest); + + User me = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + User manager = userRepository.findById(request.getAssigneeId()) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + + Task task = Task.builder() + .title(request.getTitle()) + .description(request.getDescription()) + .priority(request.getPriority()) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(request.getDueDate()) + .build(); + + Task savedTask = taskRepository.save(task); + + return TaskResponse.of(savedTask); + } + + @Transactional(readOnly = true) + public TaskResponse findTask(Long id) { + + Task task = taskRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + + return TaskResponse.of(task); + } + + @Transactional(readOnly = true) + public ListResponse findTasks(Pageable pageable, String search, + ProcessStatus status) { + + Page tasks = taskRepository.findTasks(search, status, pageable); + + return ListResponse.builder() + .content(tasks.getContent().stream().map(TaskResponse::of).collect(Collectors.toList())) + .totalElements(tasks.getTotalElements()) + .size(tasks.getSize()) + .number(tasks.getNumber()) + .totalPages(tasks.getTotalPages()) + .build(); + } + + @Transactional + public void deleteTask(Long id) { + + Task task = taskRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + + task.delete(); + } + + @Transactional + public TaskResponse updateTaskStatus(Long id, TaskStatusUpdateRequest request) { + Task task = taskRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + + taskUpdateValid(task, request.getStatus()); + + task.updateTaskStatus(request.getStatus()); + + return TaskResponse.of(task); + } + + @Transactional + public TaskResponse updateTask(Long id, TaskSaveRequest request) { + + Task task = taskRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + User assignee = userRepository.findById(request.getAssigneeId()) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_USER)); + + taskUpdateValid(task, request.getStatus()); + + task.updateTask(request.getTitle(), request.getDescription(), request.getPriority(), + assignee, + request.getDueDate(), request.getStatus()); + + return TaskResponse.of(task); + } + + @Transactional(readOnly = true) + public Task findById(Long id) { + return taskRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.NONEXISTENT_TASK)); + } + + private void taskUpdateValid(Task task, ProcessStatus status) { + if (task.getStatus() == ProcessStatus.DONE) { + if (status != ProcessStatus.DONE) { + throw new CustomException(ErrorCode.BAD_REQUEST_STATUS); + } + } + + if (task.getStatus() == ProcessStatus.TODO) { + if (status != ProcessStatus.TODO && status != ProcessStatus.IN_PROGRESS) { + throw new CustomException(ErrorCode.BAD_REQUEST_STATUS); + } + task.taskStart(); + } + + if (task.getStatus() == ProcessStatus.IN_PROGRESS) { + if (status != ProcessStatus.IN_PROGRESS && status != ProcessStatus.DONE) { + throw new CustomException(ErrorCode.BAD_REQUEST_STATUS); + } + } + } +} diff --git a/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java b/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java new file mode 100644 index 0000000..9a2a32d --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/common/LoginGroup.java @@ -0,0 +1,4 @@ +package com.example.onederful.domain.user.common; + +public interface LoginGroup { +} diff --git a/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java b/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java new file mode 100644 index 0000000..559625a --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/common/PasswordGroup.java @@ -0,0 +1,4 @@ +package com.example.onederful.domain.user.common; + +public interface PasswordGroup { +} diff --git a/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java b/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java new file mode 100644 index 0000000..0e42206 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/common/SignupGroup.java @@ -0,0 +1,4 @@ +package com.example.onederful.domain.user.common; + +public interface SignupGroup { +} diff --git a/src/main/java/com/example/onederful/domain/user/common/UserMapper.java b/src/main/java/com/example/onederful/domain/user/common/UserMapper.java new file mode 100644 index 0000000..b12f9f2 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/common/UserMapper.java @@ -0,0 +1,40 @@ +package com.example.onederful.domain.user.common; + +import com.example.onederful.domain.user.dto.*; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.enums.Role; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + + +public class UserMapper { + + // Dto → Entity + public static User user (RequestDto dto){ + return User.builder() + .username(dto.getUsername()) + .email(dto.getEmail()) + .password(dto.getPassword()) + .name(dto.getName()) + .role(Role.USER) + .isDeleted(false) + .build(); + } + + // ResponseBody data (유저 정보) (Entity → Dto) + public static UserResponseDto data(User user){ + // LocalDateTime -> OffsetDateTime + OffsetDateTime createAt = user.getCreatedAt().atOffset(ZoneOffset.UTC); + + return new UserResponseDto( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getName(), + user.getRole(), + createAt + ); + } + + +} diff --git a/src/main/java/com/example/onederful/domain/user/controller/AuthController.java b/src/main/java/com/example/onederful/domain/user/controller/AuthController.java new file mode 100644 index 0000000..f53a8be --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/controller/AuthController.java @@ -0,0 +1,82 @@ +package com.example.onederful.domain.user.controller; + +import com.example.onederful.common.ApiResponseDto; +import com.example.onederful.domain.user.common.LoginGroup; +import com.example.onederful.domain.user.common.PasswordGroup; +import com.example.onederful.domain.user.common.SignupGroup; +import com.example.onederful.domain.user.dto.*; +import com.example.onederful.domain.user.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class AuthController { + + private final UserService userService; + + // 회원가입 + @PostMapping("/auth/register") + public ResponseEntity register(@Validated(SignupGroup.class) @RequestBody RequestDto requestDto){ + + UserResponseDto signup = userService.signup(requestDto); + + ApiResponseDto success = ApiResponseDto.success("회원가입이 성공하였습니다.", signup); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 로그인 + @PostMapping("/auth/login") + public ResponseEntity login(@Validated(LoginGroup.class) @RequestBody RequestDto requestDto){ + + Tokeninfo token = userService.login(requestDto); + + ApiResponseDto success = ApiResponseDto.success("로그인이 완료되었습니다.", token); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 현재 사용자 정보 조회 + @GetMapping("/users/me") + public ResponseEntity select (HttpServletRequest request){ + + UserResponseDto select = userService.select(request); + + ApiResponseDto success = ApiResponseDto.success("사용자가 정보를 조회했습니다.", select); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 회원 탈퇴 (계정 삭제) + @PostMapping("/auth/withdraw") + public ResponseEntity withdraw (HttpServletRequest request, + @Validated(PasswordGroup.class) @RequestBody RequestDto dto){ + + userService.withdraw(request,dto); + + ApiResponseDto success = ApiResponseDto.success("회원탈퇴가 완료되었습니다.", null); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + // 모든 회원 정보 조회 + @GetMapping("/users") + public ResponseEntity selectAll(){ + List selectAll = userService.selectAll(); + + ApiResponseDto success = ApiResponseDto.success("요청이 성공적으로 처리되었습니다.",selectAll); + + return ResponseEntity.status(HttpStatus.OK).body(success); + } + + + +} diff --git a/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java b/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java new file mode 100644 index 0000000..f8ef9ec --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/dto/RequestDto.java @@ -0,0 +1,38 @@ +package com.example.onederful.domain.user.dto; + +import com.example.onederful.domain.user.common.LoginGroup; +import com.example.onederful.domain.user.common.PasswordGroup; +import com.example.onederful.domain.user.common.SignupGroup; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class RequestDto { + @NotBlank(groups = {SignupGroup.class, LoginGroup.class}, message = "사용자명은 필수입니다.") + @Pattern(groups = {SignupGroup.class, LoginGroup.class}, + regexp = "^[a-zA-Z0-9]{4,20}$", + message = "사용자명은 4-20자의 영문/숫자만 허용됩니다") + private String username; + + @NotBlank (groups = {SignupGroup.class} , message = "이메일은 필수입니다.") + @Email (groups = {SignupGroup.class}, message = "유효한 이메일 형식이 아닙니다.") + private String email; + + @NotBlank (groups = {SignupGroup.class,LoginGroup.class, PasswordGroup.class} , message = "비밀번호는 필수입니다.") + @Pattern (groups = {SignupGroup.class,LoginGroup.class, PasswordGroup.class}, + regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[^\\w\\s]).+$", + message = "비밀번호는 대문자·소문자·숫자·특수문자를 각각 1자 이상 포함해야 합니다." + ) + private String password; + + @NotBlank (groups = {SignupGroup.class}, message = "이름은 필수입니다.") + @Size(groups = {SignupGroup.class}, min = 2, max = 50, message = "이름은 2~50자 사이어야 합니다.") + private String name; +} diff --git a/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java b/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java new file mode 100644 index 0000000..d5da589 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/dto/Tokeninfo.java @@ -0,0 +1,10 @@ +package com.example.onederful.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Tokeninfo { + private String token; +} diff --git a/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java b/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java new file mode 100644 index 0000000..e027bd2 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/dto/UserResponseDto.java @@ -0,0 +1,19 @@ +package com.example.onederful.domain.user.dto; + +import com.example.onederful.domain.user.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Getter; + + +import java.time.OffsetDateTime; + +@Getter +@AllArgsConstructor +public class UserResponseDto { + private Long id; + private String username; + private String email; + private String name; + private Role role; + private OffsetDateTime createdAt; +} diff --git a/src/main/java/com/example/onederful/domain/user/entity/User.java b/src/main/java/com/example/onederful/domain/user/entity/User.java new file mode 100644 index 0000000..83554be --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/entity/User.java @@ -0,0 +1,69 @@ +package com.example.onederful.domain.user.entity; + +import com.example.onederful.domain.user.enums.Role; +import jakarta.persistence.*; + +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Where; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Builder +@Getter +@Table(name = "users") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@Where(clause = "is_deleted = false") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name="password", nullable = false) + private String password; + + @Column(name = "name", nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name= "role", nullable = false) + private Role role; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder.Default + @Column(name="is_deleted", nullable = false) + private Boolean isDeleted = false; + + public void setEncodedPassword(String encodedPassword){ + this.password = encodedPassword; + } + + public void delete() { + this.isDeleted = true; + } +} diff --git a/src/main/java/com/example/onederful/domain/user/enums/Role.java b/src/main/java/com/example/onederful/domain/user/enums/Role.java new file mode 100644 index 0000000..b3cc86a --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.onederful.domain.user.enums; + +public enum Role { + ADMIN, USER +} diff --git a/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java b/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..fa642f7 --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.example.onederful.domain.user.repository; + +import com.example.onederful.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + boolean existsByUsername(String username); + Optional findByUsername(String username); + +} diff --git a/src/main/java/com/example/onederful/domain/user/service/UserService.java b/src/main/java/com/example/onederful/domain/user/service/UserService.java new file mode 100644 index 0000000..4e1520f --- /dev/null +++ b/src/main/java/com/example/onederful/domain/user/service/UserService.java @@ -0,0 +1,143 @@ +package com.example.onederful.domain.user.service; + +import com.example.onederful.config.PasswordEncoder; +import com.example.onederful.domain.user.common.UserMapper; +import com.example.onederful.domain.user.dto.RequestDto; +import com.example.onederful.domain.user.dto.Tokeninfo; +import com.example.onederful.domain.user.dto.UserResponseDto; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.exception.CustomException; +import com.example.onederful.exception.ErrorCode; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + + // 회원가입 + @Transactional + public UserResponseDto signup(RequestDto dto){ + + // 이메일 중복 확인 + if(userRepository.existsByEmail(dto.getEmail())){ + throw new CustomException(ErrorCode.DUPLICATE_EMAIL); + } + + + // 아이디 중복 확인 + if(userRepository.existsByUsername(dto.getUsername())){ + throw new CustomException(ErrorCode.DUPLICATE_USER); + } + + // Dto → Entity + User user = UserMapper.user(dto); + + // 비밀번호 암호화 + user.setEncodedPassword(passwordEncoder.encode(user.getPassword())); + + User savedUser = userRepository.save(user); + + // ResponseBody data(유저 정보) + return UserMapper.data(savedUser); + } + + + // 로그인 + public Tokeninfo login(RequestDto dto){ + String username = dto.getUsername(); + String password = dto.getPassword(); + + + User user = userRepository.findByUsername(username).orElseThrow( + () -> new CustomException(ErrorCode.BAD_REQUEST) + ); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + // JWT Token + String token = jwtUtil.generateToken(user); + + // ResponseBody data(Token) + return token(token); + } + + + // 회원 정보 조회 + @Transactional + public UserResponseDto select(HttpServletRequest request){ + + // 토큰에서 Id 가져오기 + Long userId = jwtUtil.extractId(request); + + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.UNAUTHORIZED) + ); + + return UserMapper.data(user); + } + + + // 회원 탈퇴 + @Transactional + public void withdraw(HttpServletRequest request , RequestDto dto){ + // 토큰에서 Id 가져오기 + Long userId = jwtUtil.extractId(request); + + // 비밀번호 + String password = dto.getPassword(); + + User user = userRepository.findById(userId).orElseThrow( + () -> new CustomException(ErrorCode.UNAUTHORIZED) + ); + + if (!passwordEncoder.matches(password,user.getPassword())) { + throw new CustomException(ErrorCode.INVALID_PASSWORD); + } + + user.delete(); + } + + + // 모든 회원 정보 조회 + public List selectAll(){ + + List result = new ArrayList<>(); + + List all = userRepository.findAll(); + + for(User user : all){ + result.add(UserMapper.data(user)); + } + + return result; + + } + + + + + // ResponseBody date (Token) + private Tokeninfo token (String token){ + + String newToken = token.substring(7); + + return new Tokeninfo(newToken); + } + + +} diff --git a/src/main/java/com/example/onederful/exception/CustomException.java b/src/main/java/com/example/onederful/exception/CustomException.java new file mode 100644 index 0000000..97a182f --- /dev/null +++ b/src/main/java/com/example/onederful/exception/CustomException.java @@ -0,0 +1,11 @@ +package com.example.onederful.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomException extends RuntimeException{ + private final ErrorCode errorCode; + +} diff --git a/src/main/java/com/example/onederful/exception/ErrorCode.java b/src/main/java/com/example/onederful/exception/ErrorCode.java new file mode 100644 index 0000000..d4d42e8 --- /dev/null +++ b/src/main/java/com/example/onederful/exception/ErrorCode.java @@ -0,0 +1,35 @@ +package com.example.onederful.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor +@Getter +public enum ErrorCode { + + // User + DUPLICATE_USER(HttpStatus.CONFLICT,"이미 존재하는 사용자명입니다."), + DUPLICATE_EMAIL(HttpStatus.CONFLICT,"이미 존재하는 이메일입니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST,"잘못된 사용자명 또는 비밀번호입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"인증이 필요합니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED,"비밀번호가 일치하지 않습니다."), + LOGOUT_FAIL(HttpStatus.UNAUTHORIZED,"로그아웃에 실패하였습니다."), + NONEXISTENT_USER(HttpStatus.BAD_REQUEST, "존재하지 않는 사용자입니다."), + + // Task + NONEXISTENT_TASK(HttpStatus.BAD_REQUEST, "존재하지 않는 테스크입니다."), + BAD_REQUEST_STATUS(HttpStatus.BAD_REQUEST, "업무 상태변경은 바로 다음 단계로만 가능합니다."), + + // Comment + NONEXISTENT_COMMENT(HttpStatus.BAD_REQUEST, "존재하지 않는 댓글입니다."), + INVALID_COMMENT(HttpStatus.BAD_REQUEST, "삭제된 댓글입니다."), + + // Log + INVALID_OR_EXPIRED_REQUEST(HttpStatus.BAD_REQUEST,"요청 정보가 유효하지 않거나 만료되었습니다."), + INVALID_ACTIVITY(HttpStatus.BAD_REQUEST,"알 수 없는 활동 유형입니다."); + + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java b/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..b999e8f --- /dev/null +++ b/src/main/java/com/example/onederful/exception/GlobalExceptionHandler.java @@ -0,0 +1,34 @@ +package com.example.onederful.exception; + +import com.example.onederful.common.ApiResponseDto; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import java.util.Optional; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e){ + ErrorCode errorCode = e.getErrorCode(); + + ApiResponseDto response = ApiResponseDto.error(errorCode.getMessage()); + + return ResponseEntity.status(errorCode.getStatus()).body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e){ + String message = Optional.ofNullable(e.getBindingResult().getFieldError()) + .map(FieldError::getDefaultMessage) + .orElse("잘못된 요청입니다."); + + ApiResponseDto response = ApiResponseDto.error(message); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } +} diff --git a/src/main/java/com/example/onederful/filter/JwtFilter.java b/src/main/java/com/example/onederful/filter/JwtFilter.java new file mode 100644 index 0000000..df25a03 --- /dev/null +++ b/src/main/java/com/example/onederful/filter/JwtFilter.java @@ -0,0 +1,80 @@ +package com.example.onederful.filter; + +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import java.time.OffsetDateTime; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter implements Filter { + + private final JwtUtil jwtUtil; + + @Override + public void doFilter( + ServletRequest servletRequest, + ServletResponse servletResponse, + FilterChain filterChain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) servletRequest; + HttpServletResponse response = (HttpServletResponse) servletResponse; + + String requestURI = request.getRequestURI(); + + String authorizationHeader = request.getHeader("Authorization"); + + if("OPTIONS".equals(request.getMethod())) { + filterChain.doFilter(servletRequest, servletResponse); + } + + // 회원가입, 로그인 경우 + if (requestURI.startsWith("/api/auth/register") || requestURI.startsWith("/api/auth/login") + || + requestURI.startsWith("/swagger-ui") || + requestURI.startsWith("/v3/api-docs") || + requestURI.startsWith("/swagger-resources") || + requestURI.startsWith("/webjars")) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + + // 토큰 존재 유무 확인 + if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")){ + errorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"인증이 필요합니다"); + return; + } + + // "Bearer" 빼고 확인 + String jwt = authorizationHeader.substring(7); + + // 토큰 검증 + String errorMessage = jwtUtil.validateToken(jwt); + if (errorMessage != null) { + errorResponse(response, HttpServletResponse.SC_FORBIDDEN, errorMessage); + return; + } + + filterChain.doFilter(servletRequest, servletResponse); + } + + // 공통 에러 응답 처리 + private void errorResponse(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setContentType("application/json;charset=utf-8"); + + String json = "{" + + "\"success\" : false," + + "\"message\": \""+ message + "\"," + + "\"data\" : null," + + "\"timestamp\" : \"" + OffsetDateTime.now() + "\"" + + "}"; + + response.getWriter().write(json); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/onederful/security/JwtUtil.java b/src/main/java/com/example/onederful/security/JwtUtil.java new file mode 100644 index 0000000..73ec59e --- /dev/null +++ b/src/main/java/com/example/onederful/security/JwtUtil.java @@ -0,0 +1,125 @@ +package com.example.onederful.security; + +import com.example.onederful.domain.user.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + // JWT Token 접두사 + public final static String BEARER_PREFIX = "Bearer "; + + // JWT Token 만료시간 + @Value("${jwt.expiration}") + public Long expirationTime; + + // JWT 서명 알고리즘 + private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; + + // 비밀 키 + @Value("${jwt.secret.key}") + private String secretKey; + + // 실제 서명에서 사용할 키 객체 + private Key key; + + + /** + * 빈 초기화 메서드 + * - 애플리케이션 실행 시 비밀키를 Base64로 디코딩 하여 key 객체를 초기화 + */ + @PostConstruct + public void init(){ + byte [] bytes = Base64.getDecoder().decode(secretKey); + key = Keys.hmacShaKeyFor(bytes); + } + + + /** + * JWT 토큰 생성 + * @param user User Entity + * @return 생성된 JWT 토큰 + */ + public String generateToken(User user){ + + Long id = user.getId(); + String username = user.getUsername(); + Date date = new Date(); + + return BEARER_PREFIX + + Jwts.builder() + .setSubject(username) + .claim("id",id) + .setIssuedAt(date) + .setExpiration(new Date(date.getTime()+ expirationTime)) + .signWith(key,signatureAlgorithm) + .compact(); + } + + + /** + * JWT 토큰 유효성 검증 + * @param token JWT 토큰 + * @return 토큰의 유효성 여부 (true : 유효, false : 유효하지 않음) + */ + public String validateToken(String token){ + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return null; // 유효함 + } catch (SecurityException | MalformedJwtException e) { + return "유효하지 않은 JWT 서명입니다."; + } catch (ExpiredJwtException e) { + return "만료된 JWT 토큰입니다."; + } catch (UnsupportedJwtException e) { + return "지원되지 않는 JWT 토큰입니다."; + } catch (IllegalArgumentException e) { + return "잘못된 JWT 토큰입니다."; + } + } + + /** + * Token에 존재하는 모든 클레임(페이로드 값)을 추출 + * @param token 검증된 JWT 토큰 (로그인 한 상태) + * @return 클라임 객체 + */ + public Claims extractAllClaims(String token){ + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + /** + * Token에 저장된 ID(기본키) 가져오기 + * @param request Request + * @return ID값 + */ + public Long extractId(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + return null; // 로그인 안 된 상태면 null + } + String token = authorizationHeader.substring(7); + return extractAllClaims(token).get("id", Long.class); + } + + + + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index fb897aa..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,3 +0,0 @@ -spring: - application: - name: onederful \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3c5778d..ec9fc33 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,4 +5,11 @@ spring: datasource: url: ${DB_URL} username: ${DB_USERNAME} - password: ${DB_PASSWORD} \ No newline at end of file + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + +jwt: + secret: + key: ${SECRET_KEY} + + expiration: 3600000 \ No newline at end of file diff --git a/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java b/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java new file mode 100644 index 0000000..4d8b014 --- /dev/null +++ b/src/test/java/com/example/onederful/domain/comment/service/CommentServiceTest.java @@ -0,0 +1,131 @@ +package com.example.onederful.domain.comment.service; + +import com.example.onederful.domain.comment.dto.CommentResponseDataDto; +import com.example.onederful.domain.comment.entity.Comment; +import com.example.onederful.domain.comment.repository.CommentRepository; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.repository.TaskRepository; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + @Mock + private CommentRepository commentRepository; + @Mock + private UserRepository userRepository; + @Mock + private TaskRepository taskRepository; + @Mock + private JwtUtil jwtUtil; + + @InjectMocks + private CommentService commentService; + + @Test + @DisplayName("지정된 테스크에 HttpServletRequest에서 사용자 정보를 가져와서 댓글을 생성 할 수 있는지") + void save() { + + // given + Long task_id = 5L; + Long user_id = 2L; + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + String content = "댓글 생성 테스트"; + + User user = mock(User.class); + Task task = mock(Task.class); + Comment comment = Comment.builder() + .id(1L) + .content(content) + .user(user) + .task(task) + .build(); + + + given(jwtUtil.extractId(httpServletRequest)).willReturn(user_id); + + given(userRepository.findById(user_id)).willReturn(Optional.of(user)); + given(taskRepository.findById(task_id)).willReturn(Optional.of(task)); + given(commentRepository.save(any(Comment.class))).willReturn(comment); + // when + CommentResponseDataDto result = commentService.save(task_id, httpServletRequest, content); + + // then + assertThat(result.getContent()).isEqualTo(content); + assertThat(result.getId()).isEqualTo(1L); + + } + + @Test + @DisplayName("새로운 댓글내용 입력했을때 그 값으로 변하는지 안하는지") + void updateComment() { + // given + Long taskId = 5L; + Long commentId = 1L; + Long userId = 10L; + String originalContent = "기존 댓글 입니다."; + String updatedContent = " 수정된 댓글 입니다."; + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + Task task = mock(Task.class); + User user = mock(User.class); + Comment comment = Comment.builder() + .id(commentId) + .content(originalContent) + .user(user) + .task(task) + .isDeleted(false) + .build(); + + given(taskRepository.findById(taskId)).willReturn(Optional.of(task)); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + given(jwtUtil.extractId(httpServletRequest)).willReturn(userId); + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + + // when + CommentResponseDataDto result = commentService.updateComment(taskId, commentId, updatedContent, httpServletRequest); + + // then + assertThat(result.getContent()).isEqualTo(updatedContent); + assertThat(result.getUpdatedAt()).isNotNull(); + + } + + @Test + @DisplayName("댓글이 삭제 되는지") + void deleteComment() { + // given + Long commentId = 1L; + LocalDateTime later = LocalDateTime.now().plusMinutes(1); + Comment comment = Comment.builder() + .id(commentId) + .isDeleted(false) + .build(); + given(commentRepository.findById(commentId)).willReturn(Optional.of(comment)); + + // when + commentService.deleteComment(commentId); + + // then + assertThat(comment.getIsDeleted()).isEqualTo(true); + assertThat(comment.getDeletedAt()).isBefore(later); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java b/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java new file mode 100644 index 0000000..866e670 --- /dev/null +++ b/src/test/java/com/example/onederful/domain/dashboard/repository/DashboardRepositoryTest.java @@ -0,0 +1,240 @@ +package com.example.onederful.domain.dashboard.repository; + +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.task.repository.TaskRepository; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.enums.Role; +import com.example.onederful.domain.user.repository.UserRepository; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import java.util.List; + +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.assertThat; + + +import java.time.LocalDateTime; + +@DataJpaTest +@ActiveProfiles("test") +@Transactional +public class DashboardRepositoryTest { + + @Autowired + private EntityManager em; + + @Autowired + private TaskRepository taskRepository; + + @Autowired + private UserRepository userRepository; + + private DashboardRepositoryImpl dashboardRepository; + + private User user; + + @Test + void 통계_정보_조회_성공(){ + //given + dashboardRepository = new DashboardRepositoryImpl(em); + + user = User.builder() + .username("iamgroot") + .email("iamgroot@example.com") + .password("Password123!") + .name("groot") + .role(Role.USER) + .build(); + + userRepository.save(user); + + taskRepository.save(Task.builder() + .title("Task1") + .description("Task1 Content") + .priority(Priority.HIGH) + .assignee(user) + .user(user) + .status(ProcessStatus.TODO) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().plusDays(1)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task2") + .description("Task2 Content") + .priority(Priority.MEDIUM) + .assignee(user) + .user(user) + .status(ProcessStatus.IN_PROGRESS) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now()) + .dueDate(LocalDateTime.now().minusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task3") + .description("Task3 Content") + .priority(Priority.HIGH) + .assignee(user) + .user(user) + .status(ProcessStatus.DONE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().minusDays(1)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task4") + .description("Task4 Content") + .priority(Priority.LOW) + .assignee(user) + .user(user) + .status(ProcessStatus.TODO) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().plusDays(1)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task5") + .description("Task5 Content") + .priority(Priority.LOW) + .assignee(user) + .user(user) + .status(ProcessStatus.DONE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().minusDays(2)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + em.flush(); + em.clear(); + + //when + StatisticsResponseDto result = dashboardRepository.getStatistics(); + + //then + assertThat(result.getTotalTaskCount()).isEqualTo(5); + assertThat(result.getTodoTaskCount()).isEqualTo(2); + assertThat(result.getInProgressTaskCount()).isEqualTo(1); + assertThat(result.getDoneTaskCount()).isEqualTo(2); + assertThat(result.getOverdueTaskCount()).isEqualTo(1); + assertThat(result.getTaskDoneRate()).isEqualTo(40.0); + } + + @Test + void 오늘_내_태스크_조회(){ + //given + dashboardRepository = new DashboardRepositoryImpl(em); + + user = User.builder() + .username("iamgroot") + .email("iamgroot@example.com") + .password("Password123!") + .name("groot") + .role(Role.USER) + .build(); + + userRepository.save(user); + + taskRepository.save(Task.builder() + .title("Task1") + .description("Task1 Content") + .priority(Priority.LOW) + .assignee(user) + .user(user) + .status(ProcessStatus.TODO) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now()) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task2") + .description("Task2 Content") + .priority(Priority.HIGH) + .assignee(user) + .user(user) + .status(ProcessStatus.TODO) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now()) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + + taskRepository.save(Task.builder() + .title("Task3") + .description("Task3 Content") + .priority(Priority.MEDIUM) + .assignee(user) + .user(user) + .status(ProcessStatus.IN_PROGRESS) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now()) + .dueDate(LocalDateTime.now().minusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task4") + .description("Task4 Content") + .priority(Priority.HIGH) + .assignee(user) + .user(user) + .status(ProcessStatus.DONE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().minusDays(1)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + taskRepository.save(Task.builder() + .title("Task5") + .description("Task5 Content") + .priority(Priority.LOW) + .assignee(user) + .user(user) + .status(ProcessStatus.DONE) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().minusDays(2)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build() + ); + + //when + List taskList = dashboardRepository.getMyTasksToday(user.getId()); + + //then + assertThat(taskList).hasSize(3); + assertThat(taskList.get(0).getPriority()).isEqualTo(Priority.HIGH); + assertThat(taskList.get(1).getPriority()).isEqualTo(Priority.MEDIUM); + assertThat(taskList.get(2).getPriority()).isEqualTo(Priority.LOW); + + } + +} diff --git a/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java b/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java new file mode 100644 index 0000000..34cdb49 --- /dev/null +++ b/src/test/java/com/example/onederful/domain/dashboard/service/DashboardServiceTest.java @@ -0,0 +1,103 @@ +package com.example.onederful.domain.dashboard.service; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import com.example.onederful.domain.dashboard.dto.MyTasksTodayResponseDto; +import com.example.onederful.domain.dashboard.dto.StatisticsResponseDto; +import com.example.onederful.domain.dashboard.repository.DashboardRepository; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.enums.Role; +import com.example.onederful.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.List; +import java.time.LocalDateTime; + + +@ExtendWith(MockitoExtension.class) +public class DashboardServiceTest { + @InjectMocks + private DashboardService dashboardService; + + @Mock + private DashboardRepository dashboardRepository; + + @Mock + private UserRepository userRepository; + + private User user; + private Task task; + @Test + void 통계_정보_조회_성공(){ + //given + StatisticsResponseDto statisticsResponseDto = + StatisticsResponseDto.builder() + .totalTaskCount(5L) + .todoTaskCount(2L) + .inProgressTaskCount(1L) + .doneTaskCount(2L) + .taskDoneRate(40.0) + .overdueTaskCount(1L) + .build(); + + given(dashboardRepository.getStatistics()).willReturn(statisticsResponseDto); + + //when + StatisticsResponseDto actualResult = dashboardService.getStatistics(); + + //then + assertThat(actualResult.getTotalTaskCount()).isEqualTo(5L); + assertThat(actualResult.getTodoTaskCount()).isEqualTo(2L); + assertThat(actualResult.getInProgressTaskCount()).isEqualTo(1L); + assertThat(actualResult.getDoneTaskCount()).isEqualTo(2L); + assertThat(actualResult.getTaskDoneRate()).isEqualTo(40.0); + } + + @Test + void 오늘_내_태스크_조회_성공(){ + //given + + user = User.builder() + .username("iamgroot") + .email("iamgroot@example.com") + .password("Password123!") + .name("groot") + .role(Role.USER) + .build(); + + task = Task.builder() + .title("Task1") + .description("Task1 Content") + .priority(Priority.HIGH) + .assignee(user) + .user(user) + .status(ProcessStatus.TODO) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .startedAt(LocalDateTime.now().plusDays(1)) + .dueDate(LocalDateTime.now().plusDays(5)) + .build(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(dashboardRepository.getMyTasksToday(1L)).willReturn(List.of(task)); + + //when + List actualResult = dashboardService.getMyTasksToday(1L); + + //then + assertThat(actualResult).hasSize(1); + MyTasksTodayResponseDto myTasksTodayResponseDto = actualResult.get(0); + assertThat(myTasksTodayResponseDto.getId()).isEqualTo(task.getId()); + assertThat(myTasksTodayResponseDto.getContents()).isEqualTo(task.getDescription()); + assertThat(myTasksTodayResponseDto.getManagerId()).isEqualTo(user.getId()); + + } +} diff --git a/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java b/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java new file mode 100644 index 0000000..9ed2995 --- /dev/null +++ b/src/test/java/com/example/onederful/domain/task/service/TaskServiceTest.java @@ -0,0 +1,431 @@ +package com.example.onederful.domain.task.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.example.onederful.domain.task.dto.request.TaskSaveRequest; +import com.example.onederful.domain.task.dto.request.TaskStatusUpdateRequest; +import com.example.onederful.domain.task.entity.Task; +import com.example.onederful.domain.task.enums.Priority; +import com.example.onederful.domain.task.enums.ProcessStatus; +import com.example.onederful.domain.task.repository.TaskRepository; +import com.example.onederful.domain.user.entity.User; +import com.example.onederful.domain.user.enums.Role; +import com.example.onederful.domain.user.repository.UserRepository; +import com.example.onederful.security.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +public class TaskServiceTest { + + @Mock + private TaskRepository taskRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private JwtUtil jwtUtil; + + @InjectMocks + private TaskService taskService; + + @Test + @DisplayName("업무 생성이 성공한다.") + void 업무_생성_성공_테스트() { + //given + TaskSaveRequest request = TaskSaveRequest.builder() + .title("title") + .description("description") + .priority(Priority.LOW) + .assigneeId(1L) + .dueDate(LocalDateTime.of(2027, 4, 2, 23, 59, 59)) + .build(); + + HttpServletRequest httpServletRequest = new MockHttpServletRequest(); + + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + User manager = User.builder() + .id(userId) + .email("manager@example.com") + .name("manager") + .password("!@A12345") + .role(Role.USER) + .username("manager") + .build(); + + Task task = Task.builder() + .id(2L) + .title(request.getTitle()) + .description(request.getDescription()) + .priority(request.getPriority()) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(request.getDueDate()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(jwtUtil.extractId(any(HttpServletRequest.class))).willReturn(userId); + given(userRepository.findById(me.getId())).willReturn(Optional.of(me)); + given(userRepository.findById(manager.getId())).willReturn(Optional.of(manager)); + given(taskRepository.save(any(Task.class))).willReturn(task); + + //when + + taskService.createTask(request, httpServletRequest); + + //then + Assertions.assertEquals(task.getTitle(), request.getTitle()); + Assertions.assertEquals(task.getDescription(), request.getDescription()); + + verify(taskRepository).save(any(Task.class)); + } + + @Test + @DisplayName("업무 세부사항 조회가 성공한다.") + void 업무_조회_성공_테스트() { + //given + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + User manager = User.builder() + .id(userId) + .email("manager@example.com") + .name("manager") + .password("!@A12345") + .role(Role.USER) + .username("manager") + .build(); + + Task task = Task.builder() + .id(2L) + .title("test") + .description("description") + .priority(Priority.LOW) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(taskRepository.findById(anyLong())).willReturn(Optional.of(task)); + + //when + + taskService.findTask(anyLong()); + + //then + + Assertions.assertEquals("test", task.getTitle()); + Assertions.assertEquals("description", task.getDescription()); + + verify(taskRepository).findById(anyLong()); + } + + @Test + @DisplayName("업무 세부사항 조회가 성공한다.") + void 업무_리스트_조회_성공_테스트() { + //given + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + User manager = User.builder() + .id(userId) + .email("manager@example.com") + .name("manager") + .password("!@A12345") + .role(Role.USER) + .username("manager") + .build(); + + Task task1 = Task.builder() + .id(2L) + .title("test1") + .description("description1") + .priority(Priority.LOW) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + Task task2 = Task.builder() + .id(2L) + .title("test2") + .description("description2") + .priority(Priority.LOW) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task1, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task1, "updatedAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task2, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task2, "updatedAt", LocalDateTime.now()); + + List list = List.of(task1, task2); + Page tasks = new PageImpl<>(list); + + Pageable pageable = PageRequest.of(0, 10); + String search = "test"; + ProcessStatus status = ProcessStatus.TODO; + + given(taskRepository.findTasks(search, status, pageable)).willReturn(tasks); + + //when + + taskService.findTasks(pageable, search, status); + + //then + Assertions.assertEquals(2, tasks.getTotalElements()); + Assertions.assertEquals(1, tasks.getTotalPages()); + + verify(taskRepository).findTasks(search, status, pageable); + } + + @Test + @DisplayName("업무 삭제가 성공한다.") + void 업무_삭제_성공_테스트() { + //given + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + User manager = User.builder() + .id(userId) + .email("manager@example.com") + .name("manager") + .password("!@A12345") + .role(Role.USER) + .username("manager") + .build(); + + Task task = Task.builder() + .id(2L) + .title("test") + .description("description") + .priority(Priority.LOW) + .assignee(manager) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(taskRepository.findById(anyLong())).willReturn(Optional.of(task)); + + //when + + taskService.deleteTask(task.getId()); + + //then + + Assertions.assertTrue(task.getIsDeleted()); + } + + @Test + @DisplayName("업무 수정이 성공한다.") + void 업무_수정_성공_테스트() { + //given + + Long id = 1L; + + TaskSaveRequest request = TaskSaveRequest.builder() + .title("title") + .description("description") + .priority(Priority.LOW) + .assigneeId(1L) + .status(ProcessStatus.IN_PROGRESS) + .dueDate(LocalDateTime.of(2027, 4, 2, 23, 59, 59)) + .build(); + + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + User manager = User.builder() + .id(request.getAssigneeId()) + .email("manager@example.com") + .name("manager") + .password("!@A12345") + .role(Role.USER) + .username("manager") + .build(); + + Task task = Task.builder() + .id(2L) + .title("test") + .description("description") + .priority(Priority.LOW) + .assignee(me) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(taskRepository.findById(anyLong())).willReturn(Optional.of(task)); + given(userRepository.findById(anyLong())).willReturn(Optional.of(manager)); + + //when + + taskService.updateTask(id, request); + + //then + + Assertions.assertEquals(task.getAssignee(), manager); + Assertions.assertEquals(ProcessStatus.IN_PROGRESS, task.getStatus()); + } + + @Test + @DisplayName("업무 상태 변경이 성공한다.") + void 업무_상태_변경_성공_테스트() { + //given + + Long id = 1L; + + TaskStatusUpdateRequest request = new TaskStatusUpdateRequest(ProcessStatus.IN_PROGRESS); + + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + Task task = Task.builder() + .id(2L) + .title("test") + .description("description") + .priority(Priority.LOW) + .assignee(me) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(taskRepository.findById(anyLong())).willReturn(Optional.of(task)); + + //when + + taskService.updateTaskStatus(id, request); + + //then + + Assertions.assertEquals(ProcessStatus.IN_PROGRESS, task.getStatus()); + } + + @Test + @DisplayName("기본 조회가 성공한다.") + void 기본_조회_성공_테스트() { + Long id = 1L; + + Long userId = 1L; + + User me = User.builder() + .id(userId) + .email("me@example.com") + .name("me1") + .password("!@A12345") + .role(Role.USER) + .username("me1") + .build(); + + Task task = Task.builder() + .id(2L) + .title("test") + .description("description") + .priority(Priority.LOW) + .assignee(me) + .user(me) + .status(ProcessStatus.TODO) + .dueDate(LocalDateTime.now()) + .build(); + + ReflectionTestUtils.setField(task, "createdAt", LocalDateTime.now()); + ReflectionTestUtils.setField(task, "updatedAt", LocalDateTime.now()); + + given(taskRepository.findById(anyLong())).willReturn(Optional.of(task)); + + taskService.findById(id); + + verify(taskRepository).findById(anyLong()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..9609027 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false + driver-class-name: org.h2.Driver + username: test + password: + + jpa: + show-sql: true + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + format_sql: true + show_sql: true + use_sql_comment: true + highlight_sql: true + +