diff --git a/build.gradle b/build.gradle index e0fd1c0..e7eb187 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ dependencies { // 메일 서버 연결 라이브러리 implementation 'org.springframework.boot:spring-boot-starter-mail' - // Jwt + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/src/main/java/com/example/Devkor_project/DevkorProjectApplication.java b/src/main/java/com/example/Devkor_project/DevkorProjectApplication.java index ffdcf88..d9b7225 100644 --- a/src/main/java/com/example/Devkor_project/DevkorProjectApplication.java +++ b/src/main/java/com/example/Devkor_project/DevkorProjectApplication.java @@ -10,7 +10,6 @@ @SpringBootApplication @EnableScheduling // SchedulerService를 위한 Annotation -@EnableAsync // 비동기 처리 허용 public class DevkorProjectApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/Devkor_project/controller/PrivacyController.java b/src/main/java/com/example/Devkor_project/controller/PrivacyController.java new file mode 100644 index 0000000..298fdd7 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/controller/PrivacyController.java @@ -0,0 +1,102 @@ +package com.example.Devkor_project.controller; + +import com.example.Devkor_project.dto.PrivacyDto; +import com.example.Devkor_project.dto.PrivacyUpdateDto; +import com.example.Devkor_project.dto.ResponseDto; +import com.example.Devkor_project.service.PrivacyService; +import com.example.Devkor_project.configuration.VersionProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; + +import java.security.Principal; +import java.util.List; + +@RestController +@RequestMapping("/api/privacy") +@RequiredArgsConstructor +@Tag(name = "Privacy 개인일정", description = "개인일정(privacy) 관련 API입니다.") +public class PrivacyController { + + private final PrivacyService privacyService; + private final VersionProvider versionProvider; + + @GetMapping + @Operation(summary = "해당 사용자의 모든 개인일정 조회") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public List getAllPrivacy(Principal principal) { + return privacyService.getAllPrivacy(principal); + } + + @GetMapping("/timetable/{timetableId}") + @Operation(summary = "특정 시간표의 개인일정 조회") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + @Parameter(in = ParameterIn.PATH, name = "timetableId", description = "조회할 시간표 ID") + public List getPrivacyByTimetable(@PathVariable ("timetableId")Long timetableId, Principal principal) { + return privacyService.getPrivacyByTimetable(timetableId, principal); + } + + +// @PostMapping +// @Operation(summary = "개인일정 생성") +// @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") +// public PrivacyDto createPrivacy(@Valid @RequestBody PrivacyDto privacyDto, Principal principal) { +// return privacyService.createPrivacy(privacyDto, principal); +// } + + @PostMapping + @Operation(summary = "개인일정 생성(요일이름 반드시 한글자로 할것!!! ex. 월, 수, 토)") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity createPrivacy(@Valid @RequestBody PrivacyDto privacyDto, Principal principal) { + PrivacyDto createdPrivacy = privacyService.createPrivacy(privacyDto, principal); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("개인일정이 성공적으로 생성되었습니다.") + .data(createdPrivacy) + .version(versionProvider.getVersion()) + .build() + ); + } + + @PutMapping("/{privacyId}") + @Operation(summary = "개인일정 수정 (이름 & 장소만 변경 가능)") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + @Parameter(in = ParameterIn.PATH, name = "privacyId", description = "수정할 개인일정 ID") + public ResponseEntity updatePrivacy(@PathVariable ("privacyId")Long privacyId, @Valid @RequestBody PrivacyUpdateDto privacyUpdateDto, Principal principal) { + return privacyService.updatePrivacy(privacyId, privacyUpdateDto, principal); + } + + @DeleteMapping("/{privacyId}") + @Operation(summary = "개인일정 삭제") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity deletePrivacy(@PathVariable ("privacyId")Long privacyId, Principal principal) { + privacyService.deletePrivacy(privacyId, principal); + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("개인 일정이 성공적으로 삭제되었습니다.") + .version("v1.2.1-alpha") + .build()); + + } + +// @PostMapping("/{timetableId}/privacy") +// @Operation(summary = "특정 시간표에 개인일정 추가(아래 timetable쪽에 똑같은 api있어서 삭제)") +// @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") +// public PrivacyDto addPrivacyToTimetable(@PathVariable Long timetableId, @Valid @RequestBody PrivacyDto privacyDto, Principal principal) { +// return privacyService.addPrivacyToTimetable(timetableId, privacyDto, principal); +// } + +// @DeleteMapping("/{timetableId}/privacy/{privacyId}") +// @Operation(summary = "특정 시간표에서 개인일정 삭제(아래 timetable쪽에 똑같은 api있어서 삭제)") +// @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") +// public void removePrivacyFromTimetable(@PathVariable Long timetableId, @PathVariable Long privacyId, Principal principal) { +// privacyService.removePrivacyFromTimetable(timetableId, privacyId, principal); +// } +} diff --git a/src/main/java/com/example/Devkor_project/controller/TimetableController.java b/src/main/java/com/example/Devkor_project/controller/TimetableController.java new file mode 100644 index 0000000..11deb8d --- /dev/null +++ b/src/main/java/com/example/Devkor_project/controller/TimetableController.java @@ -0,0 +1,128 @@ +package com.example.Devkor_project.controller; + +import com.example.Devkor_project.dto.*; +import com.example.Devkor_project.entity.Timetable; +import com.example.Devkor_project.service.TimetableService; +import com.example.Devkor_project.configuration.VersionProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.security.Principal; + +@RestController +@RequestMapping("/api/timetables") +@RequiredArgsConstructor +@Tag(name = "Timetable 시간표", description = "시간표(timetable) 관련 API입니다.") +public class TimetableController { + + private final TimetableService timetableService; + private final VersionProvider versionProvider; + + /** 🟢 로그인된 사용자 시간표 생성 */ + @PostMapping + @Operation(summary = "시간표 생성") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity createTimetable( + @Valid @RequestBody TimetableDto timetableDto, + Principal principal + ) { + Timetable timetable = timetableService.createTimetableForProfile(timetableDto, principal); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("시간표가 성공적으로 생성되었습니다.") + .data(timetable) + .version(versionProvider.getVersion()) + .build()); + } + + /** 🟢 강의 추가 */ + @PostMapping("/{timetableId}/course/{courseId}") + @Operation(summary = "시간표에 강의 추가") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity addCourseToTimetable(@PathVariable("timetableId") Long timetableId, @PathVariable("courseId") Long courseId, Principal principal) { + return timetableService.addCourseToTimetable(timetableId, courseId, principal); + } + + /** 🟢 강의 제거 */ + @DeleteMapping("/{timetableId}/course/{courseId}") + @Operation(summary = "시간표에서 강의 제거") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity removeCourseFromTimetable(@PathVariable("timetableId") Long timetableId, @PathVariable ("courseId")Long courseId, Principal principal) { + return timetableService.removeCourseFromTimetable(timetableId, courseId, principal); + } + + /** 🟢 개인 일정 추가 */ + @PostMapping("/{timetableId}/privacy/{privacyId}") + @Operation(summary = "시간표에 개인 일정 추가(이렇게 추후에 추가해도 되고, privacy 애초에 POST할때 시간표 id지정해서 넣어주는 것도 가능") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity addPrivacyToTimetable(@PathVariable("timetableId") Long timetableId, @PathVariable("privacyId") Long privacyId, Principal principal) { + return timetableService.addPrivacyToTimetable(timetableId, privacyId, principal); + } + +// /** 🟢 로그인된 사용자의 모든 시간표 조회 */ +// @GetMapping +// @Operation(summary = "로그인된 사용자의 정보와 시간표들 조회(너무 복잡. 잘안쓰일듯)") +// @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") +// +// public ResponseEntity getAllTimetablesForUser(Principal principal) { +// return timetableService.getAllTimetablesForUser(principal); +// } + + /** 🟢 로그인된 사용자의 시간표 이름과 ID 조회 */ + @GetMapping("/names-and-ids") + @Operation(summary = "로그인된 사용자의 시간표 이름과 ID 조회(이게 목록 불러오기임)") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity getTimetableNamesAndIds(Principal principal) { + return timetableService.getTimetableNamesAndIds(principal); + } + + /** 🟢 특정 시간표 조회 */ + @GetMapping("/{timetableId}") + @Operation(summary = "특정 시간표 조회") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity getTimetableById(@PathVariable("timetableId") Long timetableId, Principal principal) { + return timetableService.getTimetableByIdWithDetails(timetableId, principal); + } + + /** 🟢 시간표에서 개인 일정 제거 */ + @DeleteMapping("/{timetableId}/privacy/{privacyId}") + @Operation(summary = "시간표에서 개인 일정 제거") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + public ResponseEntity removePrivacyFromTimetable( + @PathVariable ("timetableId")Long timetableId, + @PathVariable ("privacyId")Long privacyId, + Principal principal + ) { + return timetableService.removePrivacyFromTimetable(timetableId, privacyId, principal); + } + + /** 🟢 시간표 이름 변경 */ + @PutMapping("/{timetableId}") + @Operation(summary = "시간표 이름 변경") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + @Parameter(in = ParameterIn.PATH, name = "timetableId", description = "이름을 변경할 시간표 ID") + public ResponseEntity updateTimetableName( + @PathVariable ("timetableId") Long timetableId, + @RequestBody TimetableDto timetableDto, + Principal principal + ) { + return timetableService.updateTimetableName(timetableId, timetableDto, principal); + } + + /** 🟢 시간표 삭제 */ + @DeleteMapping("/{timetableId}") + @Operation(summary = "시간표 삭제") + @Parameter(in = ParameterIn.HEADER, name = "Authorization", description = "Bearer {access token}") + @Parameter(in = ParameterIn.PATH, name = "timetableId", description = "삭제할 시간표 ID") + public ResponseEntity deleteTimetable(@PathVariable ("timetableId") Long timetableId, Principal principal) { + return timetableService.deleteTimetable(timetableId, principal); + } +} diff --git a/src/main/java/com/example/Devkor_project/dto/CourseAssignmentDto.java b/src/main/java/com/example/Devkor_project/dto/CourseAssignmentDto.java new file mode 100644 index 0000000..4473ad7 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/CourseAssignmentDto.java @@ -0,0 +1,18 @@ +package com.example.Devkor_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "시간표에 강의를 추가하기 위한 요청 DTO") +public class CourseAssignmentDto { + + @NotNull(message = "courseId는 null일 수 없습니다.") + @Schema(description = "추가할 강의 ID") + private Long courseId; +} diff --git a/src/main/java/com/example/Devkor_project/dto/CourseDto.java b/src/main/java/com/example/Devkor_project/dto/CourseDto.java index d29666d..cecebd0 100644 --- a/src/main/java/com/example/Devkor_project/dto/CourseDto.java +++ b/src/main/java/com/example/Devkor_project/dto/CourseDto.java @@ -877,4 +877,24 @@ public static CourseDto.UPDATED_ExpiredDetail UPDATED_entityToExpiredDetail( .isBookmark(isBookmark) .build(); } + @AllArgsConstructor + @NoArgsConstructor + @Getter + @ToString + @Builder + public static class Basic { // ✅ 내부 static 클래스로 선언되어 있어야 함 + private Long course_id; + private String course_code; + private String name; + private String professor; + } + + public static CourseDto.Basic fromCourse(Course course) { + return CourseDto.Basic.builder() + .course_id(course.getCourse_id()) + .course_code(course.getCourse_code()) + .name(course.getName()) + .professor(course.getProfessor()) + .build(); + } } diff --git a/src/main/java/com/example/Devkor_project/dto/PrivacyAssignmentDto.java b/src/main/java/com/example/Devkor_project/dto/PrivacyAssignmentDto.java new file mode 100644 index 0000000..d73d803 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/PrivacyAssignmentDto.java @@ -0,0 +1,18 @@ +package com.example.Devkor_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Schema(description = "시간표에 개인 일정을 추가하기 위한 요청 DTO") +public class PrivacyAssignmentDto { + + @NotNull(message = "[privacyId] cannot be null.") + @Schema(description = "추가할 개인 일정 ID") + private Long privacyId; +} diff --git a/src/main/java/com/example/Devkor_project/dto/PrivacyDto.java b/src/main/java/com/example/Devkor_project/dto/PrivacyDto.java new file mode 100644 index 0000000..d5e2ffa --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/PrivacyDto.java @@ -0,0 +1,63 @@ +package com.example.Devkor_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import com.example.Devkor_project.entity.Privacy; +import lombok.*; + +import java.time.LocalTime; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PrivacyDto { + + @Schema(description = "Privacy ID") + private Long id; + +// @NotNull(message = "[timetableId] cannot be null... 널이어도 되나? 되는듯") + + @Schema(description = "Timetable IDs") // Timetable과 연결된 ID + private List timetableIds; + + @NotBlank(message = "[name] cannot be blank.") + @Schema(description = "개인 일정 이름") + private String name; + + @NotBlank(message = "[day] cannot be blank.") + @Schema(description = "요일 (e.g., 월, 화, 수)") + private String day; + + @NotNull(message = "[startTime] cannot be null.") + @Schema(description = "시작 시간") + private LocalTime startTime; + + @NotNull(message = "[finishTime] cannot be null.") + @Schema(description = "종료 시간") + private LocalTime finishTime; + + @Schema(description = "장소") + private String location; + + @Schema(description = "생성 시간") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간") + private LocalDateTime updatedAt; + + public static PrivacyDto fromPrivacy(Privacy privacy) { + return PrivacyDto.builder() + .id(privacy.getId()) + .name(privacy.getName()) + .day(privacy.getDay()) + .startTime(privacy.getStartTime()) + .finishTime(privacy.getFinishTime()) + .location(privacy.getLocation()) + .build(); + } +} diff --git a/src/main/java/com/example/Devkor_project/dto/PrivacyUpdateDto.java b/src/main/java/com/example/Devkor_project/dto/PrivacyUpdateDto.java new file mode 100644 index 0000000..783c182 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/PrivacyUpdateDto.java @@ -0,0 +1,24 @@ +package com.example.Devkor_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalTime; + +@Getter +@NoArgsConstructor +public class PrivacyUpdateDto { + //개인일정은 이름, 장소만 바꿀 수 있음 + //요일.시간 바꾸는건 아예 새로운 일정으로 "생성"하는게 깔끔 + //요일.시간 바꾸면... 모든 시간표에서 겹치지 않게 double check 하느라 비효율적 + @NotBlank(message = "이름은 필수 입력 항목입니다.") + @Schema(description = "개인 일정 이름") + private String name; + + + @Schema(description = "장소") + private String location; +} diff --git a/src/main/java/com/example/Devkor_project/dto/ProfileDto.java b/src/main/java/com/example/Devkor_project/dto/ProfileDto.java index 8b2253c..f75e2d8 100644 --- a/src/main/java/com/example/Devkor_project/dto/ProfileDto.java +++ b/src/main/java/com/example/Devkor_project/dto/ProfileDto.java @@ -68,6 +68,8 @@ public static class Profile @AllArgsConstructor @ToString @Getter + @Setter + @Builder public static class Signup { @NotBlank(message = "[email] cannot be blank.") diff --git a/src/main/java/com/example/Devkor_project/dto/TimeLocationDto.java b/src/main/java/com/example/Devkor_project/dto/TimeLocationDto.java new file mode 100644 index 0000000..85aa512 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/TimeLocationDto.java @@ -0,0 +1,17 @@ +package com.example.Devkor_project.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class TimeLocationDto { + private String day; // 요일 (월, 화, 수 등) + private Integer startPeriod; // 시작 교시 (or 시작 시간) + private Integer endPeriod; // 끝 교시 (or 끝 시간) + private String location; // 장소 (강의실 or 개인 일정 장소) +} diff --git a/src/main/java/com/example/Devkor_project/dto/TimetableDto.java b/src/main/java/com/example/Devkor_project/dto/TimetableDto.java new file mode 100644 index 0000000..65ad0f2 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/TimetableDto.java @@ -0,0 +1,27 @@ +package com.example.Devkor_project.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@ToString +@Getter +@Builder +@Schema(description = "응답 성공 DTO") +public class TimetableDto { +// @NotBlank(message = "[profileId] cannot be blank.") +// @Schema(description = "profile ID") +// private Long profileId; + + @Schema(description = "Timetable ID") + private Long id; + + @NotBlank(message = "[name] cannot be blank.") + @Schema(description = "시간표 이름 (예: 25-1학기)") + private String name; +} diff --git a/src/main/java/com/example/Devkor_project/dto/TimetableWithDetailsDto.java b/src/main/java/com/example/Devkor_project/dto/TimetableWithDetailsDto.java new file mode 100644 index 0000000..ce4b026 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/dto/TimetableWithDetailsDto.java @@ -0,0 +1,26 @@ +package com.example.Devkor_project.dto; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@ToString +@Getter +@Builder +@Schema(description = "응답 성공 DTO") +public class TimetableWithDetailsDto { + + private Long id; + private Long profileId; + private String name; + private List courses; // 연관된 Course 정보 + private List privacies; // 연관된 Privacy 정보 + + +} diff --git a/src/main/java/com/example/Devkor_project/entity/Course.java b/src/main/java/com/example/Devkor_project/entity/Course.java index dd730ef..a04c401 100644 --- a/src/main/java/com/example/Devkor_project/entity/Course.java +++ b/src/main/java/com/example/Devkor_project/entity/Course.java @@ -5,6 +5,8 @@ import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; @Entity @@ -37,4 +39,8 @@ public class Course @Column private String credit; @Column private String time_location; @Column(nullable = false) private int COUNT_comments; + + // Timetable와의 N:N 관계 추가 + @ManyToMany(mappedBy = "courses") // Timetable에서 설정한 필드명 + private List timetables = new ArrayList<>(); } diff --git a/src/main/java/com/example/Devkor_project/entity/Privacy.java b/src/main/java/com/example/Devkor_project/entity/Privacy.java new file mode 100644 index 0000000..2e5a965 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/entity/Privacy.java @@ -0,0 +1,77 @@ +package com.example.Devkor_project.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Privacy { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; // 개인 일정 이름 + + @Column(nullable = false) + private String day; // 요일 (e.g., 월, 화, 수) + + @Column(nullable = false) + private LocalTime startTime; // 시작 시간 + + @Column(nullable = false) + private LocalTime finishTime; // 종료 시간 + + private String location; // 장소 + + // Timetable과 N:N 관계 설정 + @ManyToMany + @JoinTable( + name = "privacy_timetable", + joinColumns = @JoinColumn(name = "privacy_id"), + inverseJoinColumns = @JoinColumn(name = "timetable_id") + ) + private List timetables = new ArrayList<>(); + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + // Builder에 timetables 처리 추가 + public static class PrivacyBuilder { + private List timetables = new ArrayList<>(); + + public PrivacyBuilder timetable(Timetable timetable) { + this.timetables.add(timetable); + return this; + } + + public PrivacyBuilder timetables(List timetables) { + this.timetables = timetables; + return this; + } + } + +} diff --git a/src/main/java/com/example/Devkor_project/entity/Timetable.java b/src/main/java/com/example/Devkor_project/entity/Timetable.java new file mode 100644 index 0000000..53518e3 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/entity/Timetable.java @@ -0,0 +1,66 @@ +package com.example.Devkor_project.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Timetable +{ + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @Column(nullable = false) + private String name; // 시간표 이름 (예: 25-1학기) + + // N:N 관계 - Course + @ManyToMany + @JoinTable( + name = "course_timetable", // 연결 테이블 이름 + joinColumns = @JoinColumn(name = "timetable_id"), // Timetable의 FK + inverseJoinColumns = @JoinColumn(name = "course_id") // Course의 FK + ) + private List courses = new ArrayList<>(); + + // N:N 관계 - Privacy + @ManyToMany + @JoinTable( + name = "privacy_timetable", // 연결 테이블 이름 + joinColumns = @JoinColumn(name = "timetable_id"), // Timetable의 FK + inverseJoinColumns = @JoinColumn(name = "privacy_id") // Privacy의 FK + ) + private List privacies = new ArrayList<>(); + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + +} diff --git a/src/main/java/com/example/Devkor_project/exception/ErrorCode.java b/src/main/java/com/example/Devkor_project/exception/ErrorCode.java index 9493bd3..b6e7817 100644 --- a/src/main/java/com/example/Devkor_project/exception/ErrorCode.java +++ b/src/main/java/com/example/Devkor_project/exception/ErrorCode.java @@ -42,7 +42,14 @@ public enum ErrorCode TRAFFIC_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 기간 동안 요청이 들어오지 않았습니다."), UNKNOWN_NOT_FOUND(HttpStatus.NOT_FOUND, "알 수 없음 계정이 존재하지 않습니다."), INVALID_PERIOD(HttpStatus.BAD_REQUEST, "해당 교시는 유효하지 않습니다."), - INVALID_TIME_LOCATION(HttpStatus.BAD_REQUEST, "해당 시간 및 장소 정보는 유효하지 않습니다."); + INVALID_TIME_LOCATION(HttpStatus.BAD_REQUEST, "해당 시간 및 장소 정보는 유효하지 않습니다."), + TIMETABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 시간표를 찾을 수 없습니다."), + PRIVACY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 개인 일정을 찾을 수 없습니다."), + DUPLICATE_ENTRY(HttpStatus.BAD_REQUEST, "이미 추가된 일정입니다."), + SCHEDULE_CONFLICT(HttpStatus.CONFLICT, "다른 일정과 시간이 겹칩니다."), + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), + ; + private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/example/Devkor_project/repository/BookmarkRepository.java b/src/main/java/com/example/Devkor_project/repository/BookmarkRepository.java index d1e2710..b2252ab 100644 --- a/src/main/java/com/example/Devkor_project/repository/BookmarkRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/BookmarkRepository.java @@ -7,9 +7,11 @@ 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; import java.util.List; +@Repository public interface BookmarkRepository extends JpaRepository { @Query(value = "SELECT * FROM bookmark WHERE profile_id = :profile_id AND course_id = :course_id", nativeQuery = true) diff --git a/src/main/java/com/example/Devkor_project/repository/CodeRepository.java b/src/main/java/com/example/Devkor_project/repository/CodeRepository.java index 562675c..a962a54 100644 --- a/src/main/java/com/example/Devkor_project/repository/CodeRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CodeRepository.java @@ -4,10 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.Optional; +@Repository public interface CodeRepository extends JpaRepository { Optional findByEmail(String email); diff --git a/src/main/java/com/example/Devkor_project/repository/CommentLikeRepository.java b/src/main/java/com/example/Devkor_project/repository/CommentLikeRepository.java index f8ee0cf..782c5ca 100644 --- a/src/main/java/com/example/Devkor_project/repository/CommentLikeRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CommentLikeRepository.java @@ -5,9 +5,11 @@ 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; import java.util.List; +@Repository public interface CommentLikeRepository extends JpaRepository { // profile_id, comment_id로 CommentLike 조회 diff --git a/src/main/java/com/example/Devkor_project/repository/CommentRatingRepository.java b/src/main/java/com/example/Devkor_project/repository/CommentRatingRepository.java index 47926cc..b84df89 100644 --- a/src/main/java/com/example/Devkor_project/repository/CommentRatingRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CommentRatingRepository.java @@ -2,6 +2,8 @@ import com.example.Devkor_project.entity.CommentRating; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface CommentRatingRepository extends JpaRepository { } diff --git a/src/main/java/com/example/Devkor_project/repository/CommentReportRepository.java b/src/main/java/com/example/Devkor_project/repository/CommentReportRepository.java index 65412c0..2e8e32b 100644 --- a/src/main/java/com/example/Devkor_project/repository/CommentReportRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CommentReportRepository.java @@ -6,9 +6,11 @@ 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; import java.util.List; +@Repository public interface CommentReportRepository extends JpaRepository { // 모든 CommentReport 개수 diff --git a/src/main/java/com/example/Devkor_project/repository/CommentRepository.java b/src/main/java/com/example/Devkor_project/repository/CommentRepository.java index d089d64..cd07dec 100644 --- a/src/main/java/com/example/Devkor_project/repository/CommentRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CommentRepository.java @@ -9,10 +9,12 @@ 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; import java.util.List; import java.util.Optional; +@Repository public interface CommentRepository extends JpaRepository { @Query(value = "SELECT * FROM comment WHERE profile_id = :profile_id AND course_id = :course_id", nativeQuery = true) diff --git a/src/main/java/com/example/Devkor_project/repository/CourseRatingRepository.java b/src/main/java/com/example/Devkor_project/repository/CourseRatingRepository.java index 140e9a5..7303dc7 100644 --- a/src/main/java/com/example/Devkor_project/repository/CourseRatingRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CourseRatingRepository.java @@ -2,6 +2,8 @@ import com.example.Devkor_project.entity.CourseRating; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +@Repository public interface CourseRatingRepository extends JpaRepository { } diff --git a/src/main/java/com/example/Devkor_project/repository/CourseRepository.java b/src/main/java/com/example/Devkor_project/repository/CourseRepository.java index 0cc5df0..b4ec9b1 100644 --- a/src/main/java/com/example/Devkor_project/repository/CourseRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/CourseRepository.java @@ -7,11 +7,13 @@ 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; import java.time.LocalDate; import java.util.List; import java.util.Optional; +@Repository public interface CourseRepository extends JpaRepository { @Query(value = "SELECT * FROM course WHERE name LIKE %:word% OR professor LIKE %:word% OR course_code LIKE %:word% ORDER BY year DESC, semester DESC, course_id DESC", diff --git a/src/main/java/com/example/Devkor_project/repository/PrivacyRepository.java b/src/main/java/com/example/Devkor_project/repository/PrivacyRepository.java new file mode 100644 index 0000000..68de30a --- /dev/null +++ b/src/main/java/com/example/Devkor_project/repository/PrivacyRepository.java @@ -0,0 +1,20 @@ +package com.example.Devkor_project.repository; + +import com.example.Devkor_project.entity.Privacy; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PrivacyRepository extends JpaRepository { + + // timetables 컬렉션에 포함된 timetable의 ID를 기준으로 쿼리 수정 + List findByTimetables_Id(Long timetableId); + // timetableIds 리스트와 연결된 모든 개인일정을 조회 + List findByTimetables_IdIn(List timetableIds); + + + // 특정 이름을 가진 개인일정을 조회 (예시로 추가) + List findByName(String name); + +} + diff --git a/src/main/java/com/example/Devkor_project/repository/ProfileRepository.java b/src/main/java/com/example/Devkor_project/repository/ProfileRepository.java index 224b402..a56a01f 100644 --- a/src/main/java/com/example/Devkor_project/repository/ProfileRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/ProfileRepository.java @@ -4,9 +4,11 @@ import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; import java.util.Optional; +@Repository public interface ProfileRepository extends JpaRepository { Optional findByEmail(String email); diff --git a/src/main/java/com/example/Devkor_project/repository/TimeLocationRepository.java b/src/main/java/com/example/Devkor_project/repository/TimeLocationRepository.java index 4440ad2..b44c17d 100644 --- a/src/main/java/com/example/Devkor_project/repository/TimeLocationRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/TimeLocationRepository.java @@ -5,11 +5,23 @@ 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; import java.util.List; +@Repository public interface TimeLocationRepository extends JpaRepository { @Query(value = "SELECT * FROM time_location WHERE course_id = :course_id", nativeQuery = true) List findByCourseId(Long course_id); + + @Query(value = "SELECT tl.* FROM time_location tl " + + "JOIN course c ON tl.course_id = c.course_id " + + "JOIN timetable_courses tc ON c.course_id = tc.course_id " + + "WHERE tc.timetable_id = :timetable_id", nativeQuery = true) + List findByTimetableId(@Param("timetable_id") Long timetableId); + + // 📌 여러 개의 course_id를 한 번에 조회하는 메서드 추가 + @Query("SELECT tl FROM TimeLocation tl WHERE tl.course_id.course_id IN :courseIds") + List findByCourseIds(@Param("courseIds") List courseIds); } diff --git a/src/main/java/com/example/Devkor_project/repository/TimetableRepository.java b/src/main/java/com/example/Devkor_project/repository/TimetableRepository.java new file mode 100644 index 0000000..bfbf606 --- /dev/null +++ b/src/main/java/com/example/Devkor_project/repository/TimetableRepository.java @@ -0,0 +1,29 @@ +package com.example.Devkor_project.repository; + +import com.example.Devkor_project.entity.Timetable; +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 TimetableRepository extends JpaRepository { + + // 특정 Profile의 모든 시간표 검색 + @Query("SELECT t FROM Timetable t WHERE t.profile.profile_id = :profileId") + List findByProfile_profileId(@Param("profileId") Long profileId); + + // 그 프로필 가진 사람의 특정 시간표(Timetable ID) 검색 + @Query("SELECT t FROM Timetable t WHERE t.id = :timetableId AND t.profile.profile_id = :profileId") + Timetable findByIdAndProfile_profileId(@Param("timetableId") Long timetableId, @Param("profileId") Long profileId); + + // Profile 이메일을 기반으로 모든 시간표 조회 + List findAllByProfile_Email(String email); + + @Query("SELECT t FROM Timetable t WHERE t.profile.email = :email") + List findTimetablesByEmail(@Param("email") String email); + + +} diff --git a/src/main/java/com/example/Devkor_project/repository/TrafficRepository.java b/src/main/java/com/example/Devkor_project/repository/TrafficRepository.java index d7be1ec..7f24987 100644 --- a/src/main/java/com/example/Devkor_project/repository/TrafficRepository.java +++ b/src/main/java/com/example/Devkor_project/repository/TrafficRepository.java @@ -4,9 +4,11 @@ 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; import java.util.List; +@Repository public interface TrafficRepository extends JpaRepository { @Query(value = "SELECT * FROM traffic WHERE api_path = :api_path AND year = :year AND month = :month", nativeQuery = true) diff --git a/src/main/java/com/example/Devkor_project/security/CustomUserDetailsService.java b/src/main/java/com/example/Devkor_project/security/CustomUserDetailsService.java index 633be5a..1bc189c 100644 --- a/src/main/java/com/example/Devkor_project/security/CustomUserDetailsService.java +++ b/src/main/java/com/example/Devkor_project/security/CustomUserDetailsService.java @@ -18,10 +18,9 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class CustomUserDetailsService implements UserDetailsService { - - @Autowired - private ProfileRepository profileRepository; +public class CustomUserDetailsService implements UserDetailsService +{ + private final ProfileRepository profileRepository; @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { diff --git a/src/main/java/com/example/Devkor_project/service/LoginService.java b/src/main/java/com/example/Devkor_project/service/LoginService.java index cf08ae9..073b3c6 100644 --- a/src/main/java/com/example/Devkor_project/service/LoginService.java +++ b/src/main/java/com/example/Devkor_project/service/LoginService.java @@ -27,11 +27,11 @@ import java.security.Principal; import java.time.LocalDate; import java.util.Objects; +import java.util.Optional; import java.util.Random; @Service @RequiredArgsConstructor -@Slf4j public class LoginService { private final ProfileRepository profileRepository; @@ -46,15 +46,15 @@ public class LoginService @Transactional public void signUp(ProfileDto.Signup dto) { - // 해당 이메일로 발송된 인증번호가 있는지 체크 + // 해당 이메일로 발송된 인증번호가 없는 경우, 예외 처리 Code code = codeRepository.findByEmail(dto.getEmail()) .orElseThrow(() -> new AppException(ErrorCode.CODE_NOT_FOUND, dto.getEmail())); - // 입력한 인증번호가 맞는지 체크 + // 입력한 인증번호가 틀린 경우, 예외 처리 if(!Objects.equals(dto.getCode(), code.getCode())) throw new AppException(ErrorCode.WRONG_CODE, dto.getEmail()); - // 이메일 중복 체크 + // 이메일이 중복된 경우, 예외 처리 profileRepository.findByEmail(dto.getEmail()) .ifPresent(user -> { throw new AppException(ErrorCode.EMAIL_DUPLICATED, dto.getEmail()); @@ -94,11 +94,8 @@ public void signUp(ProfileDto.Signup dto) if(dto.getUsername().isEmpty() || dto.getUsername().length() > 10) throw new AppException(ErrorCode.INVALID_USERNAME, dto.getUsername()); - // 닉네임 중복 체크 - profileRepository.findByUsername(dto.getUsername()) - .ifPresent(user -> { - throw new AppException(ErrorCode.USERNAME_DUPLICATED, dto.getUsername()); - }); + // 닉네임이 중복된 경우, 예외 처리 + checkUsername(dto.getUsername()); // 학위가 'MASTER' 또는 'DEGREE'인지 체크 if(!Objects.equals(dto.getDegree(), "MASTER") && !Objects.equals(dto.getDegree(), "DOCTOR")) @@ -127,10 +124,11 @@ public void signUp(ProfileDto.Signup dto) @Transactional public void sendAuthenticationNumber(String email, String purpose) { - // 고려대 이메일인지 확인 + // 고려대 이메일이 아닌 경우, 예외 처리 if(!email.endsWith("@korea.ac.kr")) throw new AppException(ErrorCode.EMAIL_NOT_KOREA, email); + // purpose 형식이 잘못된 경우, 예외 처리 if(!purpose.equals("SIGN_UP") && !purpose.equals("RESET_PASSWORD")) throw new AppException(ErrorCode.INVALID_PURPOSE, purpose); @@ -213,12 +211,11 @@ else if(purpose.equals("RESET_PASSWORD") && profileRepository.findByEmail(email) ); // 이미 인증번호가 발송된 이메일인 경우, 데이터베이스에서 인증번호 정보 삭제 - Code code = codeRepository.findByEmail(email).orElse(null); - if(code != null) - codeRepository.delete(code); + Optional existingCode = codeRepository.findByEmail(email); + existingCode.ifPresent(codeRepository::delete); // 인증번호를 데이터베이스에 저장 - code = Code.builder() + Code code = Code.builder() .email(email) .code(authenticationNumber) .created_at(LocalDate.now()) @@ -247,19 +244,19 @@ else if(purpose.equals("RESET_PASSWORD") && profileRepository.findByEmail(email) @Transactional public void checkAuthenticationNumber(String email, String code) { - // 고려대 이메일인지 확인 + // 고려대 이메일이 아닌 경우, 예외 처리 if (!email.endsWith("@korea.ac.kr")) throw new AppException(ErrorCode.EMAIL_NOT_KOREA, email); - // 해당 이메일로 발송된 인증번호가 있는지 체크 + // 해당 이메일로 발송된 인증번호가 없는 경우, 예외 처리 Code actualCode = codeRepository.findByEmail(email) .orElseThrow(() -> new AppException(ErrorCode.CODE_NOT_FOUND, email)); - // 입력한 인증번호가 맞는지 체크 + // 입력한 인증번호가 틀린 경우, 예외 처리 if(!Objects.equals(code, actualCode.getCode())) throw new AppException(ErrorCode.WRONG_CODE, email); - // 이메일 중복 체크 + // 이메일 중복된 경우, 예외 처리 profileRepository.findByEmail(email) .ifPresent(user -> { throw new AppException(ErrorCode.EMAIL_DUPLICATED, email); @@ -280,7 +277,7 @@ public void checkUsername(String username) @Transactional public void resetPassword(ProfileDto.ResetPassword dto) { - // 고려대 이메일인지 확인 + // 고려대 이메일이 아닌 경우 if (!dto.getEmail().endsWith("@korea.ac.kr")) throw new AppException(ErrorCode.EMAIL_NOT_KOREA, dto.getEmail()); @@ -368,7 +365,7 @@ public ProfileDto.CheckLogin checkLogin(Principal principal) .build(); } - /* access token 재발급 서비스 */ + /* Access token 재발급 서비스 */ @Transactional public String refreshToken(HttpServletRequest request) { diff --git a/src/main/java/com/example/Devkor_project/service/PrivacyService.java b/src/main/java/com/example/Devkor_project/service/PrivacyService.java new file mode 100644 index 0000000..dd4c6dc --- /dev/null +++ b/src/main/java/com/example/Devkor_project/service/PrivacyService.java @@ -0,0 +1,172 @@ +package com.example.Devkor_project.service; + +import com.example.Devkor_project.dto.PrivacyDto; +import com.example.Devkor_project.dto.PrivacyUpdateDto; +import com.example.Devkor_project.dto.ResponseDto; +import com.example.Devkor_project.entity.Privacy; +import com.example.Devkor_project.entity.Timetable; +import com.example.Devkor_project.exception.AppException; +import com.example.Devkor_project.exception.ErrorCode; +import com.example.Devkor_project.repository.PrivacyRepository; +import com.example.Devkor_project.repository.TimetableRepository; +import lombok.RequiredArgsConstructor; +import jakarta.transaction.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.security.Principal; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PrivacyService { + + private final PrivacyRepository privacyRepository; + private final TimetableRepository timetableRepository; + + // 특정 시간표의 개인일정 조회 + @Transactional + public List getPrivacyByTimetable(Long timetableId, Principal principal) { + validateTimetableOwnership(timetableId, principal); + return privacyRepository.findByTimetables_Id(timetableId).stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + // 사용자의 모든 개인일정 조회 + @Transactional + public List getAllPrivacy(Principal principal) { + String userEmail = principal.getName(); + + List timetableIds = timetableRepository.findAllByProfile_Email(userEmail) + .stream() + .map(Timetable::getId) + .collect(Collectors.toList()); + + return privacyRepository.findByTimetables_IdIn(timetableIds).stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + // 개인일정 생성 + @Transactional + public PrivacyDto createPrivacy(PrivacyDto privacyDto, Principal principal) { + List timetables = validateTimetablesOwnership(privacyDto.getTimetableIds(), principal); + + Privacy privacy = Privacy.builder() + .timetables(timetables) + .name(privacyDto.getName()) + .day(privacyDto.getDay()) + .startTime(privacyDto.getStartTime()) + .finishTime(privacyDto.getFinishTime()) + .location(privacyDto.getLocation()) + .build(); + + return convertToDto(privacyRepository.save(privacy)); + } + + // 개인일정 수정 + @Transactional + public ResponseEntity updatePrivacy(Long privacyId, PrivacyUpdateDto updateDto, Principal principal) { + Privacy privacy = privacyRepository.findById(privacyId) + .orElseThrow(() -> new AppException(ErrorCode.PRIVACY_NOT_FOUND, "해당 개인 일정을 찾을 수 없습니다.")); + + validateTimetablesOwnership(privacy.getTimetables().stream() + .map(Timetable::getId) + .collect(Collectors.toList()), principal); + + // 기존 일정 정보 업데이트 + privacy.setName(updateDto.getName()); + privacy.setLocation(updateDto.getLocation()); + + privacyRepository.save(privacy); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("개인 일정이 성공적으로 수정되었습니다.") + .data(convertToDto(privacy)) + .version("v1.1.4") + .build()); + } + + + // 개인일정 삭제 + @Transactional + public void deletePrivacy(Long id, Principal principal) { + Privacy privacy = privacyRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Privacy not found")); + + validateTimetablesOwnership(privacy.getTimetables().stream() + .map(Timetable::getId) + .collect(Collectors.toList()), principal); + + privacyRepository.deleteById(id); + } + + // 특정 시간표에 개인일정 추가 + @Transactional + public PrivacyDto addPrivacyToTimetable(Long timetableId, PrivacyDto privacyDto, Principal principal) { + Timetable timetable = validateTimetableOwnership(timetableId, principal); + + Privacy privacy = Privacy.builder() + .name(privacyDto.getName()) + .day(privacyDto.getDay()) + .startTime(privacyDto.getStartTime()) + .finishTime(privacyDto.getFinishTime()) + .location(privacyDto.getLocation()) + .build(); + + privacy.getTimetables().add(timetable); + return convertToDto(privacyRepository.save(privacy)); + } + + // 특정 시간표에서 개인일정 삭제 + @Transactional + public void removePrivacyFromTimetable(Long timetableId, Long privacyId, Principal principal) { + validateTimetableOwnership(timetableId, principal); + + Privacy privacy = privacyRepository.findById(privacyId) + .orElseThrow(() -> new RuntimeException("Privacy not found")); + + privacy.getTimetables().removeIf(timetable -> timetable.getId().equals(timetableId)); + privacyRepository.save(privacy); + } + + // 시간표 소유권 확인 + private Timetable validateTimetableOwnership(Long timetableId, Principal principal) { + return timetableRepository.findById(timetableId) + .filter(timetable -> timetable.getProfile().getEmail().equals(principal.getName())) + .orElseThrow(() -> new RuntimeException("You do not have access to this timetable")); + } + + // 여러 시간표 소유권 확인 + private List validateTimetablesOwnership(List timetableIds, Principal principal) { + List timetables = timetableRepository.findAllById(timetableIds); + boolean isOwner = timetables.stream() + .allMatch(timetable -> timetable.getProfile().getEmail().equals(principal.getName())); + + if (!isOwner) { + throw new RuntimeException("You do not have access to one or more timetables"); + } + + return timetables; + } + + private PrivacyDto convertToDto(Privacy privacy) { + return PrivacyDto.builder() + .id(privacy.getId()) + .timetableIds(privacy.getTimetables().stream() + .map(Timetable::getId) + .collect(Collectors.toList())) + .name(privacy.getName()) + .day(privacy.getDay()) + .startTime(privacy.getStartTime()) + .finishTime(privacy.getFinishTime()) + .location(privacy.getLocation()) + .createdAt(privacy.getCreatedAt()) + .updatedAt(privacy.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/Devkor_project/service/TimetableService.java b/src/main/java/com/example/Devkor_project/service/TimetableService.java new file mode 100644 index 0000000..e33746e --- /dev/null +++ b/src/main/java/com/example/Devkor_project/service/TimetableService.java @@ -0,0 +1,319 @@ +package com.example.Devkor_project.service; + +import com.example.Devkor_project.dto.*; +import com.example.Devkor_project.entity.*; +import com.example.Devkor_project.exception.AppException; +import com.example.Devkor_project.exception.ErrorCode; +import com.example.Devkor_project.repository.CourseRepository; +import com.example.Devkor_project.repository.PrivacyRepository; +import com.example.Devkor_project.repository.ProfileRepository; +import com.example.Devkor_project.repository.TimetableRepository; +import com.example.Devkor_project.repository.TimeLocationRepository; +import com.example.Devkor_project.util.TimeLocationUtil; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.security.Principal; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TimetableService { + + private final TimetableRepository timetableRepository; + private final TimeLocationRepository timeLocationRepository; + private final CourseRepository courseRepository; + private final PrivacyRepository privacyRepository; + private final ProfileRepository profileRepository; + + /** + * 로그인된 사용자의 시간표 생성 + */ + @Transactional + public Timetable createTimetableForProfile(TimetableDto timetableDto, Principal principal) { + Profile profile = getProfileByPrincipal(principal); + + // Timetable 생성 + Timetable timetable = Timetable.builder() + .profile(profile) + .name(timetableDto.getName()) + .build(); + + return timetableRepository.save(timetable); + } + + /** + * 로그인된 사용자의 모든 시간표 조회 + */ + @Transactional + public ResponseEntity getAllTimetablesForUser(Principal principal) { + Profile profile = getProfileByPrincipal(principal); + + List timetables = timetableRepository.findByProfile_profileId(profile.getProfile_id()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("모든 시간표를 성공적으로 조회하였습니다.") + .data(timetables) + .version("v1.1.4") + .build()); + } + + /** + * 로그인된 사용자의 시간표 이름과 ID 조회 + */ + @Transactional + public ResponseEntity getTimetableNamesAndIds(Principal principal) { + Profile profile = getProfileByPrincipal(principal); + + List timetableDtos = timetableRepository.findByProfile_profileId(profile.getProfile_id()).stream() + .map(t -> new TimetableDto(t.getId(), t.getName())) + .collect(Collectors.toList()); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("시간표 이름과 ID를 성공적으로 조회하였습니다.") + .data(timetableDtos) + .version("v1.1.4") + .build()); + } + + /** + * 특정 시간표 조회 (상세 정보 포함) + */ + @Transactional + public ResponseEntity getTimetableByIdWithDetails(Long timetableId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + // DTO 변환 + TimetableWithDetailsDto dto = TimetableWithDetailsDto.builder() + .id(timetable.getId()) + .profileId(timetable.getProfile().getProfile_id()) + .name(timetable.getName()) + .courses(timetable.getCourses().stream() + .map(CourseDto::fromCourse) // ✅ CourseDto 변환 메서드 사용 + .collect(Collectors.toList())) + .privacies(timetable.getPrivacies().stream() + .map(PrivacyDto::fromPrivacy) // ✅ PrivacyDto 변환 메서드 사용 + .collect(Collectors.toList())) + .build(); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("특정 시간표를 성공적으로 조회하였습니다.") + .data(dto) + .version("v1.1.4") + .build()); + } + + + /** + * 시간표에 강의 추가 + */ + @Transactional + public ResponseEntity addCourseToTimetable(Long timetableId, Long courseId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + if (courseId == null) { + throw new AppException(ErrorCode.INVALID_REQUEST, "courseId는 null일 수 없습니다."); + } + + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new AppException(ErrorCode.COURSE_NOT_FOUND, "해당 강의를 찾을 수 없습니다.")); + + if (timetable.getCourses() == null) { + timetable.setCourses(new ArrayList<>()); // 🛠️ Null 체크 후 리스트 초기화 + } + if (course.getTimetables() == null) { + course.setTimetables(new ArrayList<>()); // 🛠️ Null 체크 후 리스트 초기화 + } + + if (timetable.getCourses().contains(course)) { + throw new AppException(ErrorCode.DUPLICATE_ENTRY, "해당 강의는 이미 추가되어 있습니다."); + } + + // ⏳ 기존 시간표의 강의 및 개인 일정 시간 정보 가져오기 + List existingTimeLocations = TimeLocationUtil.extractFromTimetable(timetable, timeLocationRepository); + + // 🕒 새 강의의 시간표 정보 가져오기 + List newCourseTimeLocations = timeLocationRepository.findByCourseIds(List.of(course.getCourse_id())) + .stream() + .map(tl -> new TimeLocationDto(tl.getDay(), tl.getStartPeriod(), tl.getEndPeriod(), tl.getLocation())) + .toList(); + + // ⚠️ 기존 일정과 새 강의 시간이 겹치는지 확인 + if (TimeLocationUtil.hasScheduleConflict(existingTimeLocations, newCourseTimeLocations)) { + throw new AppException(ErrorCode.SCHEDULE_CONFLICT, "시간표에 이미 같은 시간에 다른 강의 또는 개인 일정이 있습니다."); + } + + // 📌 겹치지 않으면 추가 진행 + timetable.getCourses().add(course); + course.getTimetables().add(timetable); + + timetableRepository.save(timetable); + courseRepository.save(course); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("강의가 시간표에 추가되었습니다.") + .version("v1.2.1-alpha") + .build()); + } + + + /** + * 시간표에서 강의 제거 + */ + @Transactional + public ResponseEntity removeCourseFromTimetable(Long timetableId, Long courseId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new AppException(ErrorCode.COURSE_NOT_FOUND, "해당 강의를 찾을 수 없습니다.")); + + timetable.getCourses().remove(course); + course.getTimetables().remove(timetable); + + timetableRepository.save(timetable); + courseRepository.save(course); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("강의가 시간표에서 제거되었습니다.") + .version("v1.1.4") + .build()); + } + + /** 🟢 시간표에 개인 일정 추가 (중복은 방지되도록 코드 수정 완) */ + @Transactional + public ResponseEntity addPrivacyToTimetable(Long timetableId, Long privacyId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + if (privacyId == null) { + throw new AppException(ErrorCode.INVALID_REQUEST, "privacyId는 null일 수 없습니다."); + } + + Privacy privacy = privacyRepository.findById(privacyId) + .orElseThrow(() -> new AppException(ErrorCode.PRIVACY_NOT_FOUND, "해당 개인 일정을 찾을 수 없습니다.")); + + if (timetable.getPrivacies() == null) { + timetable.setPrivacies(new ArrayList<>()); // 🛠️ Null 체크 후 리스트 초기화 + } + if (privacy.getTimetables() == null) { + privacy.setTimetables(new ArrayList<>()); // 🛠️ Null 체크 후 리스트 초기화 + } + + if (timetable.getPrivacies().contains(privacy)) { + throw new AppException(ErrorCode.DUPLICATE_ENTRY, "해당 개인 일정은 이미 추가되어 있습니다."); + } + + // ⏳ 기존 시간표의 강의 및 개인 일정 시간 정보 가져오기 + List existingTimeLocations = TimeLocationUtil.extractFromTimetable(timetable, timeLocationRepository); + + // 🕒 새 개인 일정의 시간표 정보 생성 + List newPrivacyTimeLocations = List.of( + new TimeLocationDto(privacy.getDay(), privacy.getStartTime().getHour(), + privacy.getFinishTime().getHour(), privacy.getLocation()) + ); + + // ⚠️ 기존 일정과 새 일정 시간이 겹치는지 확인 + if (TimeLocationUtil.hasScheduleConflict(existingTimeLocations, newPrivacyTimeLocations)) { + throw new AppException(ErrorCode.SCHEDULE_CONFLICT, "시간표에 이미 같은 시간에 다른 강의 또는 개인 일정이 있습니다."); + } + + // 📌 겹치지 않으면 추가 진행 + timetable.getPrivacies().add(privacy); + privacy.getTimetables().add(timetable); + + timetableRepository.save(timetable); + privacyRepository.save(privacy); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("개인 일정이 시간표에 추가되었습니다.") + .version("v1.2.1-alpha") + .build()); + } + + + + /** + * 시간표에서 개인 일정 제거 + */ + @Transactional + public ResponseEntity removePrivacyFromTimetable(Long timetableId, Long privacyId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + Privacy privacy = privacyRepository.findById(privacyId) + .orElseThrow(() -> new AppException(ErrorCode.TIMETABLE_NOT_FOUND, "해당 개인 일정을 찾을 수 없습니다.")); + + timetable.getPrivacies().remove(privacy); + privacy.getTimetables().remove(timetable); + + timetableRepository.save(timetable); + privacyRepository.save(privacy); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("시간표에서 개인 일정이 성공적으로 제거되었습니다.") + .version("v1.1.4") + .build()); + } + @Transactional + public ResponseEntity updateTimetableName(Long timetableId, TimetableDto timetableDto, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + // 이름 변경 + timetable.setName(timetableDto.getName()); + timetableRepository.save(timetable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("시간표 이름이 성공적으로 변경되었습니다.") + .data(new TimetableDto(timetable.getId(), timetable.getName())) + .version("v1.1.4") + .build()); + } + /** + * 🟢 시간표 삭제 + */ + @Transactional + public ResponseEntity deleteTimetable(Long timetableId, Principal principal) { + Timetable timetable = validateProfileOwnership(timetableId, principal); + + // 📌 시간표에서 모든 강의 및 개인 일정 관계 해제 + timetable.getCourses().forEach(course -> course.getTimetables().remove(timetable)); + timetable.getPrivacies().forEach(privacy -> privacy.getTimetables().remove(timetable)); + + // 📌 시간표 삭제 + timetableRepository.delete(timetable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ResponseDto.Success.builder() + .message("시간표가 성공적으로 삭제되었습니다.") + .version("v1.2.1-alpha") + .build()); + } + + + /** + * Helper Method: Principal을 이용해 Profile 조회 + */ + private Profile getProfileByPrincipal(Principal principal) { + return profileRepository.findByEmail(principal.getName()) + .orElseThrow(() -> new AppException(ErrorCode.EMAIL_NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + /** + * Helper Method: 주어진 시간표 ID가 현재 로그인된 사용자의 것인지 검증 + */ + private Timetable validateProfileOwnership(Long timetableId, Principal principal) { + return timetableRepository.findById(timetableId) + .filter(timetable -> timetable.getProfile().getEmail().equals(principal.getName())) + .orElseThrow(() -> new AppException(ErrorCode.INVALID_TOKEN, "접근 권한이 없습니다.")); + } +} diff --git a/src/main/java/com/example/Devkor_project/util/TimeLocationUtil.java b/src/main/java/com/example/Devkor_project/util/TimeLocationUtil.java new file mode 100644 index 0000000..e8d256d --- /dev/null +++ b/src/main/java/com/example/Devkor_project/util/TimeLocationUtil.java @@ -0,0 +1,56 @@ +package com.example.Devkor_project.util; +import com.example.Devkor_project.dto.TimeLocationDto; +import com.example.Devkor_project.entity.Course; +import com.example.Devkor_project.entity.Privacy; +import com.example.Devkor_project.entity.TimeLocation; +import com.example.Devkor_project.dto.TimetableDto; +import com.example.Devkor_project.entity.Timetable; +import com.example.Devkor_project.exception.AppException; +import com.example.Devkor_project.exception.ErrorCode; +import com.example.Devkor_project.repository.TimeLocationRepository; + +import java.util.ArrayList; +import java.util.List; +public class TimeLocationUtil { + + // 🕒 시간표에서 기존 강의 & 개인 일정 가져오기 + public static List extractFromTimetable(Timetable timetable, TimeLocationRepository timeLocationRepository) { + List existingTimeLocations = new ArrayList<>(); // 빈 리스트 생성 (가변 가능) + + // 📌 기존 강의의 시간표 정보 가져오기 + List courseTimeLocations = timeLocationRepository.findByCourseIds( + timetable.getCourses().stream().map(Course::getCourse_id).toList() + ); + + existingTimeLocations.addAll(courseTimeLocations.stream() + .map(tl -> new TimeLocationDto(tl.getDay(), tl.getStartPeriod(), tl.getEndPeriod(), tl.getLocation())) + .toList()); + + // 📌 기존 개인 일정의 시간 정보 추가 + existingTimeLocations.addAll(timetable.getPrivacies().stream() + .map(p -> new TimeLocationDto(p.getDay(), p.getStartTime().getHour(), p.getFinishTime().getHour(), p.getLocation())) + .toList()); + + return existingTimeLocations; + } + + + // ⚠️ **스케줄 충돌 여부 확인 함수 추가** + public static boolean hasScheduleConflict(List existingSchedules, List newSchedules) { + for (TimeLocationDto existing : existingSchedules) { + for (TimeLocationDto newSchedule : newSchedules) { + if (existing.getDay().equals(newSchedule.getDay()) && + isTimeOverlapping(existing.getStartPeriod(), existing.getEndPeriod(), + newSchedule.getStartPeriod(), newSchedule.getEndPeriod())) { + return true; + } + } + } + return false; + } + + // ⏳ 시간이 겹치는지 확인 + private static boolean isTimeOverlapping(int start1, int end1, int start2, int end2) { + return start1 <= end2 && start2 <= end1; + } +} \ No newline at end of file diff --git a/src/test/java/com/example/Devkor_project/DevkorProjectApplication.java b/src/test/java/com/example/Devkor_project/DevkorProjectApplication.java deleted file mode 100644 index bc20281..0000000 --- a/src/test/java/com/example/Devkor_project/DevkorProjectApplication.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.example.Devkor_project; - -// import com.example.Devkor_project.controller.LoginController; -// import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -// import org.mockito.Mock; -// import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -// import org.springframework.test.web.servlet.MockMvc; -// import org.springframework.test.web.servlet.RequestBuilder; -// import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -// import static -// org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; - -@SpringBootTest -class DevkorProjectApplication { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/example/Devkor_project/DevkorProjectApplicationTests.java b/src/test/java/com/example/Devkor_project/DevkorProjectApplicationTests.java new file mode 100644 index 0000000..c5157e9 --- /dev/null +++ b/src/test/java/com/example/Devkor_project/DevkorProjectApplicationTests.java @@ -0,0 +1,13 @@ +package com.example.Devkor_project; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DevkorProjectApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/example/Devkor_project/service/LoginServiceTest.java b/src/test/java/com/example/Devkor_project/service/LoginServiceTest.java new file mode 100644 index 0000000..f0ca2fe --- /dev/null +++ b/src/test/java/com/example/Devkor_project/service/LoginServiceTest.java @@ -0,0 +1,554 @@ +package com.example.Devkor_project.service; + +import com.example.Devkor_project.dto.ProfileDto; +import com.example.Devkor_project.entity.Profile; +import com.example.Devkor_project.exception.AppException; +import com.example.Devkor_project.exception.ErrorCode; +import com.example.Devkor_project.repository.CodeRepository; +import com.example.Devkor_project.repository.ProfileRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.mail.internet.MimeMessage; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.BDDMockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.security.Principal; +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class LoginServiceTest +{ + @Autowired MockMvc mockMvc; + @Autowired(required = false) LoginService loginService; + + @Autowired ProfileRepository profileRepository; + @Autowired CodeRepository codeRepository; + + @MockBean JavaMailSender javaMailSender; + + private String email; + private ProfileDto.Signup profileDto; + private MimeMessage mimeMessage; + + /* 테스트 별 초기 설정 */ + @BeforeEach + void conditionalSetUp(TestInfo testInfo) + { + email = "test1234@korea.ac.kr"; + profileDto = ProfileDto.Signup.builder() + .email(email) + .password("test1234") + .username("test1234") + .student_id("1234567") + .degree("MASTER") + .semester(1) + .department("test") + .build(); + mimeMessage = mock(MimeMessage.class); + } + + // profileDto에 해당하는 계정 생성 + void createAccount(String email) + { + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setEmail(email); + profileDto.setCode(code); + loginService.signUp(profileDto); + } + + @Test + @DisplayName("회원가입 프로세스 성공") + void join_process_success() throws Exception + { + // Given + createAccount(email); + + // Then + verify(javaMailSender, times(1)).send(mimeMessage); + assertThat(profileRepository.findByEmail(email).isPresent()).isTrue(); + + mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", profileDto.getEmail()) + .param("password", profileDto.getPassword()) + .param("remember-me", "true")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("회원가입 프로세스 실패 1 : 이메일 인증번호 전송 시, 고려대 이메일이 아닌 경우") + void join_process_failure_1() + { + // Given + email = "test1234@naver.com"; + + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.sendAuthenticationNumber(email, "SIGN_UP") + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.EMAIL_NOT_KOREA); + } + + @Test + @DisplayName("회원가입 프로세스 실패 2 : purpose 형식이 잘못된 경우") + void join_process_failure_2() + { + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.sendAuthenticationNumber(email, "wrong_purpose") + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_PURPOSE); + } + + @Test + @DisplayName("회원가입 프로세스 실패 3 : 이메일 인증번호 확인 시, 고려대 이메일이 아닌 경우") + void join_process_failure_3() + { + // Given + email = "test1234@naver.com"; + String code = "12345678"; + + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.checkAuthenticationNumber(email, code) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.EMAIL_NOT_KOREA); + } + + @Test + @DisplayName("회원가입 프로세스 실패 4 : 이메일 인증번호 확인 시, 해당 이메일로 발송된 인증번호가 없는 경우") + void join_process_failure_4() + { + // Given + String code = "12345678"; + + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.checkAuthenticationNumber(email, code) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.CODE_NOT_FOUND); + } + + @Test + @DisplayName("회원가입 프로세스 실패 5 : 이메일 인증번호 확인 시, 입력한 인증번호가 틀린 경우") + void join_process_failure_5() + { + // Given + String code = "12345678"; + + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + AppException exception = assertThrows( + AppException.class, + () -> loginService.checkAuthenticationNumber(email, code) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.WRONG_CODE); + } + + @Test + @DisplayName("회원가입 프로세스 실패 6 : 이메일 인증번호 확인 시, 이메일이 중복된 경우") + void join_process_failure_6() + { + // Given + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + createAccount(email); + + String code = codeRepository.findByEmail(email).get().getCode(); + + AppException exception = assertThrows( + AppException.class, + () -> loginService.checkAuthenticationNumber(email, code) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.EMAIL_DUPLICATED); + } + + @Test + @DisplayName("회원가입 프로세스 실패 7 : 회원가입 시, 해당 이메일로 발송된 인증번호가 없는 경우") + void join_process_failure_7() + { + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.CODE_NOT_FOUND); + } + + @Test + @DisplayName("회원가입 프로세스 실패 8 : 회원가입 시, 입력한 인증번호가 틀린 경우") + void join_process_failure_8() + { + // Given + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setCode("wrong_code"); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.WRONG_CODE); + } + + @Test + @DisplayName("회원가입 프로세스 실패 9 : 회원가입 시, 이메일이 중복된 경우") + void join_process_failure_9() + { + // Given + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + createAccount(email); + code = codeRepository.findByEmail(email).get().getCode(); + + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.EMAIL_DUPLICATED); + } + + @Test + @DisplayName("회원가입 프로세스 실패 10 : 회원가입 시, 학번 형식 오류") + void join_process_failure_10() + { + // Given + profileDto.setStudent_id("1234567890"); + + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_STUDENT_ID); + } + + @ParameterizedTest(name = "회원가입 시, 비밀번호 형식 오류 {index} : password = {0}") + @CsvSource({ + "test1", + "test123456789012345678901234567890", + "testtesttest", + "12345678901234567890" + }) + @DisplayName("회원가입 프로세스 실패 11 : 회원가입 시, 비밀번호 형식 오류") + void join_process_failure_11(String password) + { + // Given + profileDto.setPassword(password); + + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_PASSWORD); + } + + @Test + @DisplayName("회원가입 프로세스 실패 12 : 회원가입 시, 닉네임 형식 오류") + void join_process_failure_12() + { + // Given + profileDto.setUsername("12345678901234567890"); + + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_USERNAME); + } + + @Test + @DisplayName("회원가입 프로세스 실패 13 : 회원가입 시, 닉네임이 중복된 경우") + void join_process_failure_13() + { + // Given + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + profileDto.setEmail("another_email@korea.ac.kr"); + createAccount("another_email@korea.ac.kr"); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setEmail(email); + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.USERNAME_DUPLICATED); + } + + @Test + @DisplayName("회원가입 프로세스 실패 14 : 회원가입 시, 학위 형식 오류") + void join_process_failure_14() + { + // Given + profileDto.setDegree("wrong_degree"); + + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When & Then + loginService.sendAuthenticationNumber(email, "SIGN_UP"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.checkAuthenticationNumber(email, code); + + profileDto.setCode(code); + AppException exception = assertThrows( + AppException.class, + () -> loginService.signUp(profileDto) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_DEGREE); + } + + @Test + @DisplayName("임시 비밀번호 발급 프로세스 성공") + void resetPassword_process_success() throws Exception + { + // Given + createAccount(email); + + // When & Then + mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", profileDto.getEmail()) + .param("password", profileDto.getPassword()) + .param("remember-me", "true")) + .andExpect(status().isOk()); + + loginService.sendAuthenticationNumber(email, "RESET_PASSWORD"); + + String code = codeRepository.findByEmail(email).get().getCode(); + loginService.resetPassword( + ProfileDto.ResetPassword.builder() + .email(profileDto.getEmail()) + .code(code) + .build() + ); + + mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", profileDto.getEmail()) + .param("password", profileDto.getPassword()) + .param("remember-me", "true")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("임시 비밀번호 발급 프로세스 실패 1 : 해당 이메일의 계정이 존재하지 않는 경우") + void resetPassword_process_failure_1() throws Exception + { + // Given + given(javaMailSender.createMimeMessage()) + .willReturn(mimeMessage); + + // When + loginService.sendAuthenticationNumber(email, "RESET_PASSWORD"); + + // Then + assertThat(codeRepository.findByEmail(email)).isEqualTo(Optional.empty()); + } + + @Test + @DisplayName("임시 비밀번호 발급 프로세스 실패 2 : 인증번호가 틀린 경우") + void resetPassword_process_failure_2() throws Exception + { + // Given + createAccount(email); + + // When & Then + mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", profileDto.getEmail()) + .param("password", profileDto.getPassword()) + .param("remember-me", "true")) + .andExpect(status().isOk()); + + loginService.sendAuthenticationNumber(email, "RESET_PASSWORD"); + + AppException exception = assertThrows( + AppException.class, + () -> loginService.resetPassword( + ProfileDto.ResetPassword.builder() + .email(profileDto.getEmail()) + .code("wrong_code") + .build() + ) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.WRONG_CODE); + } + + @Test + @DisplayName("로그인 여부 확인 성공") + void checkLogin_success() + { + // Given + createAccount(email); + + Principal mockPrincipal = mock(Principal.class); + given(mockPrincipal.getName()).willReturn(email); + + // When + ProfileDto.CheckLogin result = loginService.checkLogin(mockPrincipal); + + // Then + assertThat(result.getEmail()).isEqualTo(email); + } + + @Test + @DisplayName("로그인 여부 확인 실패 : 계정이 존재하지 않는 경우") + void checkLogin_failure() + { + // Given + Principal mockPrincipal = mock(Principal.class); + given(mockPrincipal.getName()).willReturn("does_not_exist@naver.com"); + + // When & Then + AppException exception = assertThrows( + AppException.class, + () -> loginService.checkLogin(mockPrincipal) + ); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.EMAIL_NOT_FOUND); + } + + @Test + @DisplayName("Access token 재발급 성공") + void refreshToken_success() throws Exception + { + // Given + createAccount(email); + + MvcResult loginResult = mockMvc.perform(post("/api/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", profileDto.getEmail()) + .param("password", profileDto.getPassword()) + .param("remember-me", "true")) + .andExpect(status().isOk()) + .andReturn(); + + String jsonResponse = loginResult.getResponse().getContentAsString(); + ObjectMapper objectMapper = new ObjectMapper(); + Map responseMap = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); + Map dataMap = (Map) responseMap.get("data"); + + String refreshToken = dataMap.get("refreshToken"); + + HttpServletRequest mockHttpServletRequest = mock(HttpServletRequest.class); + given(mockHttpServletRequest.getHeader("Authorization")).willReturn("Bearer " + refreshToken); + + // When + String newAccessToken = loginService.refreshToken(mockHttpServletRequest); + + // Then + assertThat(newAccessToken).isNotEmpty(); + assertThat(newAccessToken).isNotNull(); + } + + @Test + @DisplayName("Access token 재발급 실패 : 올바르지 않은 refresh token") + void refreshToken_failure() + { + // Given + String refreshToken = "thisIsNotOfficialRefreshToken"; + + HttpServletRequest mockHttpServletRequest = mock(HttpServletRequest.class); + given(mockHttpServletRequest.getHeader("Authorization")).willReturn("Bearer " + refreshToken); + + // When & Then + assertThrows( + AppException.class, + () -> loginService.refreshToken(mockHttpServletRequest) + ); + } +} \ No newline at end of file