diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/controller/ApplicationDraftController.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/controller/ApplicationDraftController.java new file mode 100644 index 0000000..9863935 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/controller/ApplicationDraftController.java @@ -0,0 +1,79 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.controller; + +import com.jobdri.jobdri_api.domain.applicationdraft.dto.request.ApplicationDraftUpsertRequest; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftSaveResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.service.ApplicationDraftService; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import com.jobdri.jobdri_api.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/application-drafts") +@Tag(name = "Application Draft", description = "지원 플로우 임시저장 API") +public class ApplicationDraftController { + + private final ApplicationDraftService applicationDraftService; + + @Operation( + summary = "내 임시저장 생성/수정", + description = "로그인한 사용자의 지원 플로우 임시저장을 생성하거나 수정합니다. 사용자당 최근 임시저장 1개만 유지합니다." + ) + @PutMapping("/me") + public ApiResponse saveOrUpdateMyDraft( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @Valid @RequestBody ApplicationDraftUpsertRequest request + ) { + return ApiResponse.onSuccess( + "임시저장되었습니다.", + applicationDraftService.saveOrUpdate(getCurrentUser(userDetails), request) + ); + } + + @Operation( + summary = "내 임시저장 조회", + description = "로그인한 사용자의 최근 임시저장 1건을 조회합니다. 임시저장이 없으면 result를 null로 반환합니다." + ) + @GetMapping("/me") + public ApiResponse getMyDraft( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + return ApiResponse.onSuccess( + null, + applicationDraftService.getMyDraft(getCurrentUser(userDetails)) + ); + } + + @Operation( + summary = "내 임시저장 삭제", + description = "로그인한 사용자의 임시저장을 삭제합니다. 임시저장이 없어도 성공 처리합니다." + ) + @DeleteMapping("/me") + public ApiResponse deleteMyDraft( + @AuthenticationPrincipal UserDetailsImpl userDetails + ) { + applicationDraftService.deleteMyDraft(getCurrentUser(userDetails)); + return ApiResponse.onSuccess("임시저장이 삭제되었습니다."); + } + + private User getCurrentUser(UserDetailsImpl userDetails) { + if (userDetails == null) { + throw new GeneralException(GeneralErrorCode.MISSING_AUTH_INFO); + } + return userDetails.getUser(); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/request/ApplicationDraftUpsertRequest.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/request/ApplicationDraftUpsertRequest.java new file mode 100644 index 0000000..4f7aa0f --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/request/ApplicationDraftUpsertRequest.java @@ -0,0 +1,21 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.dto.request; + +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraftStep; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record ApplicationDraftUpsertRequest( + @NotNull(message = "작성 단계는 필수입니다.") + ApplicationDraftStep step, + + @NotNull(message = "지원 유형은 필수입니다.") + ApplyType type, + + Long postingId, + Long middleCategoryId, + Long smallCategoryId, + List selectedQuestionIds +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftResponse.java new file mode 100644 index 0000000..9807392 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftResponse.java @@ -0,0 +1,19 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.dto.response; + +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraftStep; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; + +import java.time.LocalDateTime; +import java.util.List; + +public record ApplicationDraftResponse( + Long draftId, + ApplicationDraftStep step, + ApplyType type, + Long postingId, + Long middleCategoryId, + Long smallCategoryId, + List selectedQuestionIds, + LocalDateTime savedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftSaveResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftSaveResponse.java new file mode 100644 index 0000000..c21fa43 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/dto/response/ApplicationDraftSaveResponse.java @@ -0,0 +1,12 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.dto.response; + +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraftStep; + +import java.time.LocalDateTime; + +public record ApplicationDraftSaveResponse( + Long draftId, + ApplicationDraftStep step, + LocalDateTime savedAt +) { +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraft.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraft.java new file mode 100644 index 0000000..6400592 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraft.java @@ -0,0 +1,140 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.entity; + +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.user.entity.User; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +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.OneToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Table(name = "application_drafts") +public class ApplicationDraft { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApplicationDraftStep step; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ApplyType type; + + @Column(name = "posting_id") + private Long postingId; + + @Column(name = "middle_category_id") + private Long middleCategoryId; + + @Column(name = "small_category_id") + private Long smallCategoryId; + + @Builder.Default + @ElementCollection + @CollectionTable( + name = "application_draft_selected_questions", + joinColumns = @JoinColumn(name = "application_draft_id") + ) + @Column(name = "question_id", nullable = false) + private List selectedQuestionIds = new ArrayList<>(); + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public static ApplicationDraft create( + User user, + ApplicationDraftStep step, + ApplyType type, + Long postingId, + Long middleCategoryId, + Long smallCategoryId, + List selectedQuestionIds + ) { + return ApplicationDraft.builder() + .user(user) + .step(step) + .type(type) + .postingId(postingId) + .middleCategoryId(middleCategoryId) + .smallCategoryId(smallCategoryId) + .selectedQuestionIds(normalizeQuestionIds(selectedQuestionIds)) + .build(); + } + + public void update( + ApplicationDraftStep step, + ApplyType type, + Long postingId, + Long middleCategoryId, + Long smallCategoryId, + List selectedQuestionIds + ) { + this.step = step; + this.type = type; + this.postingId = postingId; + this.middleCategoryId = middleCategoryId; + this.smallCategoryId = smallCategoryId; + this.selectedQuestionIds.clear(); + this.selectedQuestionIds.addAll(normalizeQuestionIds(selectedQuestionIds)); + this.updatedAt = LocalDateTime.now(); + } + + public LocalDateTime getSavedAt() { + return updatedAt; + } + + @PrePersist + void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + if (this.updatedAt == null) { + this.updatedAt = LocalDateTime.now(); + } + } + + private static List normalizeQuestionIds(List selectedQuestionIds) { + if (selectedQuestionIds == null) { + return new ArrayList<>(); + } + return new ArrayList<>(selectedQuestionIds); + } +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraftStep.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraftStep.java new file mode 100644 index 0000000..0007e46 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/entity/ApplicationDraftStep.java @@ -0,0 +1,9 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.entity; + +public enum ApplicationDraftStep { + TYPE_SELECT, + COMPANY_SELECT, + JOB_SELECT, + QUESTION_SELECT, + ANSWER_WRITE +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/repository/ApplicationDraftRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/repository/ApplicationDraftRepository.java new file mode 100644 index 0000000..9a2c154 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/repository/ApplicationDraftRepository.java @@ -0,0 +1,13 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.repository; + +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraft; +import com.jobdri.jobdri_api.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ApplicationDraftRepository extends JpaRepository { + Optional findByUser(User user); + + void deleteByUser(User user); +} diff --git a/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftService.java b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftService.java new file mode 100644 index 0000000..adc8a66 --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftService.java @@ -0,0 +1,76 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.service; + +import com.jobdri.jobdri_api.domain.applicationdraft.dto.request.ApplicationDraftUpsertRequest; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftSaveResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraft; +import com.jobdri.jobdri_api.domain.applicationdraft.repository.ApplicationDraftRepository; +import com.jobdri.jobdri_api.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; + +@Service +@RequiredArgsConstructor +public class ApplicationDraftService { + + private final ApplicationDraftRepository applicationDraftRepository; + + @Transactional + public ApplicationDraftSaveResponse saveOrUpdate(User user, ApplicationDraftUpsertRequest request) { + ApplicationDraft draft = applicationDraftRepository.findByUser(user) + .map(existingDraft -> { + existingDraft.update( + request.step(), + request.type(), + request.postingId(), + request.middleCategoryId(), + request.smallCategoryId(), + request.selectedQuestionIds() + ); + return existingDraft; + }) + .orElseGet(() -> applicationDraftRepository.save(ApplicationDraft.create( + user, + request.step(), + request.type(), + request.postingId(), + request.middleCategoryId(), + request.smallCategoryId(), + request.selectedQuestionIds() + ))); + + return new ApplicationDraftSaveResponse( + draft.getId(), + draft.getStep(), + draft.getSavedAt() + ); + } + + @Transactional(readOnly = true) + public ApplicationDraftResponse getMyDraft(User user) { + return applicationDraftRepository.findByUser(user) + .map(this::toResponse) + .orElse(null); + } + + @Transactional + public void deleteMyDraft(User user) { + applicationDraftRepository.deleteByUser(user); + } + + private ApplicationDraftResponse toResponse(ApplicationDraft draft) { + return new ApplicationDraftResponse( + draft.getId(), + draft.getStep(), + draft.getType(), + draft.getPostingId(), + draft.getMiddleCategoryId(), + draft.getSmallCategoryId(), + new ArrayList<>(draft.getSelectedQuestionIds()), + draft.getSavedAt() + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftServiceTest.java new file mode 100644 index 0000000..933ec0e --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/domain/applicationdraft/service/ApplicationDraftServiceTest.java @@ -0,0 +1,107 @@ +package com.jobdri.jobdri_api.domain.applicationdraft.service; + +import com.jobdri.jobdri_api.domain.applicationdraft.dto.request.ApplicationDraftUpsertRequest; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.dto.response.ApplicationDraftSaveResponse; +import com.jobdri.jobdri_api.domain.applicationdraft.entity.ApplicationDraftStep; +import com.jobdri.jobdri_api.domain.applicationdraft.repository.ApplicationDraftRepository; +import com.jobdri.jobdri_api.domain.mockapply.entity.ApplyType; +import com.jobdri.jobdri_api.domain.user.entity.User; +import com.jobdri.jobdri_api.domain.user.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class ApplicationDraftServiceTest { + + @Autowired + private ApplicationDraftService applicationDraftService; + + @Autowired + private ApplicationDraftRepository applicationDraftRepository; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("사용자의 임시저장이 없으면 새로 생성한다") + void saveOrUpdateCreatesDraft() { + User user = saveUser("draft-create@example.com"); + ApplicationDraftUpsertRequest request = new ApplicationDraftUpsertRequest( + ApplicationDraftStep.COMPANY_SELECT, + ApplyType.ACTUAL, + 1001L, + null, + null, + List.of(1L, 2L) + ); + + ApplicationDraftSaveResponse response = applicationDraftService.saveOrUpdate(user, request); + ApplicationDraftResponse draft = applicationDraftService.getMyDraft(user); + + assertThat(response.draftId()).isNotNull(); + assertThat(response.step()).isEqualTo(ApplicationDraftStep.COMPANY_SELECT); + assertThat(response.savedAt()).isNotNull(); + assertThat(draft.type()).isEqualTo(ApplyType.ACTUAL); + assertThat(draft.postingId()).isEqualTo(1001L); + assertThat(draft.selectedQuestionIds()).containsExactly(1L, 2L); + assertThat(applicationDraftRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("사용자의 임시저장이 이미 있으면 기존 데이터 1건을 수정한다") + void saveOrUpdateUpdatesExistingDraft() { + User user = saveUser("draft-update@example.com"); + applicationDraftService.saveOrUpdate(user, new ApplicationDraftUpsertRequest( + ApplicationDraftStep.COMPANY_SELECT, + ApplyType.ACTUAL, + 1001L, + null, + null, + List.of(1L) + )); + + ApplicationDraftSaveResponse updated = applicationDraftService.saveOrUpdate(user, new ApplicationDraftUpsertRequest( + ApplicationDraftStep.JOB_SELECT, + ApplyType.MOCK, + null, + 10L, + 100L, + null + )); + ApplicationDraftResponse draft = applicationDraftService.getMyDraft(user); + + assertThat(applicationDraftRepository.count()).isEqualTo(1); + assertThat(updated.step()).isEqualTo(ApplicationDraftStep.JOB_SELECT); + assertThat(draft.type()).isEqualTo(ApplyType.MOCK); + assertThat(draft.postingId()).isNull(); + assertThat(draft.middleCategoryId()).isEqualTo(10L); + assertThat(draft.smallCategoryId()).isEqualTo(100L); + assertThat(draft.selectedQuestionIds()).isEmpty(); + } + + @Test + @DisplayName("임시저장이 없어도 삭제는 성공하고 조회 결과는 null이다") + void deleteIsIdempotent() { + User user = saveUser("draft-delete@example.com"); + + applicationDraftService.deleteMyDraft(user); + + assertThat(applicationDraftService.getMyDraft(user)).isNull(); + assertThat(applicationDraftRepository.count()).isZero(); + } + + private User saveUser(String email) { + return userRepository.save(User.signup("테스트 사용자", email, "encoded-password")); + } +}