Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/job-application-archive-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 지원관리 보관함 API

기본 경로는 `/api/job-applications`이며 모든 API는 로그인 사용자가 소유한 카드만 처리한다.

## 카드 보관과 복원

- `POST /{jobApplicationId}/archive`: 카드에 보관 시각을 기록하고 활성 보드에서 제외한다. 빠진 카드가 있던 단계의 수동 순서는 0부터 연속으로 다시 정리한다.
- `POST /{jobApplicationId}/restore`: 카드가 보관 전에 속했던 단계를 유지하며 해당 단계의 마지막 순서로 복원한다.

이미 보관된 카드를 다시 보관하거나 활성 카드를 복원하면 `409 JOB_APPLICATION_4091`을 반환한다.

## 보관함 조회

`GET /archive?page=0&size=10`은 현재 사용자의 보관 카드만 `archivedAt DESC, id DESC` 순서로 페이지 조회한다. `page`는 최소 0으로, `size`는 1부터 공통 최대 페이지 크기까지 보정한다.

## 영구 삭제

`DELETE /{jobApplicationId}`는 지원 카드와 카드가 직접 소유한 기술 태그·체크리스트·정량 스펙·실제 제출용 자기소개서만 삭제한다. `sourceJobPostingId`가 가리키는 저장 공고와 `mockApplyId`가 가리키는 모의지원 및 분석 결과는 삭제하지 않는다.

반대로 출처 공고나 연결된 모의지원이 먼저 삭제되면 연결 ID만 `null`이 되며 카드의 독립 스냅샷은 유지된다.
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.jobdri.jobdri_api.domain.jobapplication.controller;

import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationCreateRequest;
import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest;
import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationDetailUpdateRequest;
import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationFromJobPostingRequest;
import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationPositionRequest;
import com.jobdri.jobdri_api.domain.jobapplication.dto.request.JobApplicationSort;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationBoardResponse;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationDetailResponse;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse;
import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationArchiveService;
import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationBoardService;
import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationDetailService;
import com.jobdri.jobdri_api.domain.jobapplication.service.JobApplicationService;
Expand All @@ -18,7 +20,9 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand All @@ -38,6 +42,7 @@ public class JobApplicationController {
private final UserService userService;
private final JobApplicationBoardService jobApplicationBoardService;
private final JobApplicationDetailService jobApplicationDetailService;
private final JobApplicationArchiveService jobApplicationArchiveService;

@Operation(summary = "지원관리 칸반 조회", description = "활성 카드를 4개 열로 반환합니다. 회사명·공고명·직무명을 검색하며 count는 검색 결과 기준입니다. CREATED_DESC에서는 드래그를 비활성화합니다.")
@GetMapping("/board")
Expand All @@ -61,6 +66,53 @@ public ApiResponse<JobApplicationBoardResponse> move(
jobApplicationBoardService.move(validateAuthenticatedUser(userDetails), jobApplicationId, request));
}

@Operation(summary = "지원 카드 보관", description = "활성 보드에서 카드를 제외하고 보관 시각을 기록합니다. 남은 열의 수동 순서는 연속으로 정리합니다.")
@PostMapping("/{jobApplicationId}/archive")
public ApiResponse<JobApplicationResponse> archive(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long jobApplicationId
) {
return ApiResponse.onSuccess(
"지원 카드 보관에 성공했습니다.",
jobApplicationArchiveService.archive(validateAuthenticatedUser(userDetails), jobApplicationId)
);
}

@Operation(summary = "지원 카드 복원", description = "보관 전 단계를 유지하며 해당 단계의 마지막 순서로 복원합니다.")
@PostMapping("/{jobApplicationId}/restore")
public ApiResponse<JobApplicationResponse> restore(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long jobApplicationId
) {
return ApiResponse.onSuccess(
"지원 카드 복원에 성공했습니다.",
jobApplicationArchiveService.restore(validateAuthenticatedUser(userDetails), jobApplicationId)
);
}

@Operation(summary = "지원 카드 보관함 조회", description = "보관 시각과 ID 내림차순으로 현재 사용자의 보관 카드를 페이지 조회합니다.")
@GetMapping("/archive")
public ApiResponse<Page<JobApplicationArchiveItemResponse>> archivePage(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
return ApiResponse.onSuccess(
"지원 카드 보관함 조회에 성공했습니다.",
jobApplicationArchiveService.getArchive(validateAuthenticatedUser(userDetails), page, size)
);
}

@Operation(summary = "지원 카드 영구 삭제", description = "지원 카드와 카드가 직접 소유한 상세 데이터만 삭제합니다. 출처 공고와 연결된 모의지원은 유지합니다.")
@DeleteMapping("/{jobApplicationId}")
public ApiResponse<Void> delete(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@PathVariable Long jobApplicationId
) {
jobApplicationArchiveService.delete(validateAuthenticatedUser(userDetails), jobApplicationId);
return ApiResponse.onSuccess("지원 카드 영구 삭제에 성공했습니다.", null);
}

@Operation(summary = "지원 카드 수동 등록", description = "분석을 시작하지 않고 독립적인 지원관리 카드를 생성합니다.")
@PostMapping
public ApiResponse<JobApplicationResponse> create(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.jobdri.jobdri_api.domain.jobapplication.dto.response;

import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage;

import java.time.LocalDateTime;

public record JobApplicationArchiveItemResponse(
Long jobApplicationId,
String companyName,
String postingName,
String jobTitle,
LocalDateTime deadlineAt,
String currentLabel,
LocalDateTime currentAt,
JobApplicationStage stage,
LocalDateTime archivedAt,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ public void moveTo(JobApplicationStage stage, int stageOrder) {
this.stageOrder = stageOrder;
}

public void archive(LocalDateTime archivedAt) {
if (archivedAt == null || this.archivedAt != null) {
throw new IllegalStateException("활성 지원 카드만 보관할 수 있습니다.");
}
this.archivedAt = archivedAt;
}

public void restore(int stageOrder) {
if (this.archivedAt == null || stageOrder < 0) {
throw new IllegalStateException("보관된 지원 카드와 0 이상의 순서가 필요합니다.");
}
this.archivedAt = null;
this.stageOrder = stageOrder;
}

public void updateDetails(
DetailClassification detailClassification,
String companyName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication;
import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse;
import jakarta.persistence.LockModeType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
Expand Down Expand Up @@ -50,11 +53,35 @@ select coalesce(max(ja.stageOrder), -1)
@Query("select ja from JobApplication ja where ja.id = :id")
Optional<JobApplication> findByIdForUpdate(@Param("id") Long id);

@Query(
value = """
select new com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse(
ja.id, ja.companyName, ja.postingName, ja.jobTitle, ja.deadlineAt,
ja.currentLabel, ja.currentAt, ja.stage, ja.archivedAt, ja.createdAt, ja.updatedAt
)
from JobApplication ja
where ja.user.id = :userId and ja.archivedAt is not null
""",
countQuery = """
select count(ja)
from JobApplication ja
where ja.user.id = :userId and ja.archivedAt is not null
"""
)
Page<JobApplicationArchiveItemResponse> findArchivePageByUserId(
@Param("userId") Long userId,
Pageable pageable
);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update JobApplication ja set ja.sourceJobPosting = null where ja.sourceJobPosting.id = :jobPostingId")
int clearSourceJobPosting(@Param("jobPostingId") Long jobPostingId);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.jobPosting.id = :jobPostingId")
int clearMockAppliesForJobPosting(@Param("jobPostingId") Long jobPostingId);

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update JobApplication ja set ja.mockApply = null where ja.mockApply.id = :mockApplyId")
int clearMockApply(@Param("mockApplyId") Long mockApplyId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.jobdri.jobdri_api.domain.jobapplication.service;

import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationArchiveItemResponse;
import com.jobdri.jobdri_api.domain.jobapplication.dto.response.JobApplicationResponse;
import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplication;
import com.jobdri.jobdri_api.domain.jobapplication.entity.JobApplicationStage;
import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository;
import com.jobdri.jobdri_api.domain.user.entity.User;
import com.jobdri.jobdri_api.domain.user.repository.UserRepository;
import com.jobdri.jobdri_api.domain.user.service.UserService;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import com.jobdri.jobdri_api.global.pagination.PaginationPolicy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JobApplicationArchiveService {
public static final int MAX_PAGE_SIZE = PaginationPolicy.MAX_PAGE_SIZE;

private final JobApplicationRepository jobApplicationRepository;
private final UserService userService;
private final UserRepository userRepository;
private final EntityManager entityManager;

@Transactional
@AuditLogEvent(action = "JOB_APPLICATION_ARCHIVE", targetType = "JOB_APPLICATION", targetId = "#arg1")
public JobApplicationResponse archive(User user, Long jobApplicationId) {
User validatedUser = lockValidatedUser(user);
JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId);
if (application.getArchivedAt() != null) {
throw conflict("이미 보관된 지원 카드입니다.");
}

List<JobApplication> column = jobApplicationRepository.lockActiveColumns(
validatedUser.getId(), List.of(application.getStage()));
application.archive(LocalDateTime.now());
reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage());
return flushAndReload(application.getId());
}

@Transactional
@AuditLogEvent(action = "JOB_APPLICATION_RESTORE", targetType = "JOB_APPLICATION", targetId = "#arg1")
public JobApplicationResponse restore(User user, Long jobApplicationId) {
User validatedUser = lockValidatedUser(user);
JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId);
if (application.getArchivedAt() == null) {
throw conflict("보관되지 않은 지원 카드는 복원할 수 없습니다.");
}

int lastOrder = jobApplicationRepository.findMaxStageOrder(validatedUser.getId(), application.getStage()) + 1;
application.restore(lastOrder);
return flushAndReload(application.getId());
}

public Page<JobApplicationArchiveItemResponse> getArchive(User user, int page, int size) {
User validatedUser = userService.validateUser(user);
PageRequest pageable = PageRequest.of(
Math.max(page, 0),
Math.min(Math.max(size, 1), MAX_PAGE_SIZE),
Sort.by(Sort.Order.desc("archivedAt"), Sort.Order.desc("id"))
);
return jobApplicationRepository.findArchivePageByUserId(validatedUser.getId(), pageable);
}

@Transactional
@AuditLogEvent(action = "JOB_APPLICATION_DELETE", targetType = "JOB_APPLICATION", targetId = "#arg1")
public void delete(User user, Long jobApplicationId) {
User validatedUser = lockValidatedUser(user);
JobApplication application = getOwnedApplicationForUpdate(validatedUser, jobApplicationId);
if (application.getArchivedAt() == null) {
List<JobApplication> column = jobApplicationRepository.lockActiveColumns(
validatedUser.getId(), List.of(application.getStage()));
reorder(column.stream().filter(card -> !card.getId().equals(jobApplicationId)).toList(), application.getStage());
}
jobApplicationRepository.delete(application);
jobApplicationRepository.flush();
}

private JobApplicationResponse flushAndReload(Long jobApplicationId) {
jobApplicationRepository.flush();
entityManager.clear();
JobApplication application = jobApplicationRepository.findDetailedById(jobApplicationId)
.orElseThrow(() -> notFound(jobApplicationId));
return JobApplicationResponse.from(application);
}

private void reorder(List<JobApplication> cards, JobApplicationStage stage) {
for (int index = 0; index < cards.size(); index++) {
cards.get(index).moveTo(stage, index);
}
}

private JobApplication getOwnedApplicationForUpdate(User user, Long jobApplicationId) {
JobApplication application = jobApplicationRepository.findByIdForUpdate(jobApplicationId)
.orElseThrow(() -> notFound(jobApplicationId));
if (!application.getUser().getId().equals(user.getId())) {
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 지원 카드에 접근할 수 없습니다.");
}
return application;
}

private User lockValidatedUser(User user) {
User validatedUser = userService.validateUser(user);
return userRepository.findByIdForUpdate(validatedUser.getId())
.orElseThrow(() -> new GeneralException(GeneralErrorCode.USER_NOT_FOUND));
}

private GeneralException notFound(Long jobApplicationId) {
return new GeneralException(
GeneralErrorCode.JOB_APPLICATION_NOT_FOUND,
"해당 지원 카드를 찾을 수 없습니다. jobApplicationId=" + jobApplicationId
);
}

private GeneralException conflict(String message) {
return new GeneralException(GeneralErrorCode.JOB_APPLICATION_UPDATE_CONFLICT, message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository;
import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository;
import com.jobdri.jobdri_api.domain.audit.annotation.AuditLogEvent;
import com.jobdri.jobdri_api.domain.jobapplication.repository.JobApplicationRepository;
import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingResponse;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingService;
Expand Down Expand Up @@ -68,6 +69,7 @@ public class MockApplyService {
private final MockApplyPersistenceService mockApplyPersistenceService;
private final MockApplySequenceService mockApplySequenceService;
private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository;
private final JobApplicationRepository jobApplicationRepository;

@Transactional(propagation = Propagation.NOT_SUPPORTED)
@AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()")
Expand Down Expand Up @@ -298,6 +300,7 @@ public void deleteMockApply(User user, Long mockApplyId) {
questionAnalysisRepository.deleteAllByMockApplyId(mockApplyId);
analysisRepository.deleteByMockApplyId(mockApplyId);
questionRepository.deleteAllByMockApplyId(mockApplyId);
jobApplicationRepository.clearMockApply(mockApplyId);
mockApplyRepository.deleteByMockApplyId(mockApplyId);
}

Expand Down
Loading
Loading