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
2 changes: 1 addition & 1 deletion docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| API-004 | POST | `/auth/refresh` | Planned | REQ-008 | refresh token rotation |
| API-005 | GET | `/user/me` | Planned | REQ-008 | 내 정보 조회 |
| API-006 | POST | `/auth/logout` | Planned | REQ-008 | 로그아웃 |
| API-007 | GET | `/cover-letters` | Planned | REQ-003 | 내 자기소개서 목록 |
| API-007 | GET | `/cover-letters` | Implemented | REQ-003 | 내 자기소개서 목록 |
| API-008 | POST | `/cover-letters` | Implemented | REQ-003 | 자기소개서 초안 생성 |
| API-009 | PUT | `/cover-letters/{coverLetterId}/basic-info` | Planned | REQ-004 | 등록 step1 저장 |
| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 |
Expand Down
3 changes: 1 addition & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메
|---|---|---|---|---|---|---|---|
| REQ-001 | 공통 예외 응답 기반 | Verified | High | 공통 에러 응답 | [#2](https://github.com/Rewrite-Team/Rewrite-BE/issues/2), [PR #3](https://github.com/Rewrite-Team/Rewrite-BE/pull/3) | `GlobalExceptionHandlerTest`, `./gradlew test` | `BusinessException`, `ErrorCode`, `GlobalExceptionHandler`, `ErrorResponse` 구현됨 |
| REQ-002 | 개발용 현재 사용자 Provider | Verified | High | 인증 필요 API 공통 | [#4](https://github.com/Rewrite-Team/Rewrite-BE/issues/4), [PR #6](https://github.com/Rewrite-Team/Rewrite-BE/pull/6) | `DevCurrentUserProviderTest`, `./gradlew test` | `CurrentUserProvider`, `DevCurrentUserProvider` 구현됨 |
| REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test` | API-008 자기소개서 초안 생성 계약과 DB/JPA persistence 전환 구현됨. 나머지 CRUD는 후속 slice 후보 기준으로 분리 진행 |
| REQ-003 | 자기소개서 기본 CRUD | In Progress | High | API-007, API-008, API-012, API-013 | [#5](https://github.com/Rewrite-Team/Rewrite-BE/issues/5), [#18](https://github.com/Rewrite-Team/Rewrite-BE/issues/18), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회와 API-008 자기소개서 초안 생성 계약 구현됨. API-012/API-013은 후속 slice 후보 기준으로 분리 진행 |
| REQ-004 | 자기소개서 등록 step 저장 | Planned | High | API-009, API-010, API-011 | - | - | DB/JPA 전환 이후 진행 권장 |
| REQ-005 | 자기소개서 제출과 LLM Job 생성 | Planned | High | API-014, API-015, API-016 | - | - | LLM provider 호출 전 skeleton 우선 |
| REQ-006 | 첨삭 버전 조회와 최종 작성본 저장 | Planned | High | API-017, API-018, API-019, API-024 | - | - | 제출/Job skeleton 이후 진행 권장 |
Expand Down Expand Up @@ -58,7 +58,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메

| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 현재 사용자 자기소개서 목록 조회 | REQ-003 | API-007 | DB/JPA repository 기반 pagination, status filter, owner filter, deletedAt 제외, controller/service/repository test |
| 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test |
| 자기소개서 soft delete | REQ-003 | API-013 | DB/JPA repository 기반 soft delete, 삭제 후 조회 제외, 하위 리소스 접근 정책 skeleton, service/web/repository test |
| 등록 step1 저장 | REQ-004 | API-009 | basic info replace, trim, URL validation, DRAFT 상태 검증, service/web test |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.daon.rewrite.coverletter.controller;

import com.daon.rewrite.coverletter.dto.CoverLetterListResponse;
import com.daon.rewrite.coverletter.dto.CreateCoverLetterResponse;
import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.service.CoverLetterService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -19,4 +25,14 @@ public class CoverLetterController {
public CreateCoverLetterResponse createDraft() {
return CreateCoverLetterResponse.from(coverLetterService.createDraft());
}

@GetMapping("/cover-letters")
public CoverLetterListResponse findMyCoverLetters(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "9") int size,
@RequestParam(required = false) CoverLetterStatus status
) {
Page<CoverLetter> result = coverLetterService.findMyCoverLetters(page, size, status);
return CoverLetterListResponse.from(result, page, size);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.daon.rewrite.coverletter.dto;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;

import java.time.LocalDateTime;
import java.time.ZoneId;

public record CoverLetterListItemResponse(
String id,
String title,
String companyName,
String positionTitle,
CoverLetterStatus status,
LocalDateTime createdAt,
String latestReviewVersionId
) {
private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul");

public static CoverLetterListItemResponse from(CoverLetter coverLetter) {
return new CoverLetterListItemResponse(
coverLetter.getId(),
coverLetter.getTitle(),
coverLetter.getCompanyName(),
coverLetter.getPositionTitle(),
coverLetter.getStatus(),
LocalDateTime.ofInstant(coverLetter.getCreatedAt(), API_ZONE),
coverLetter.getLatestReviewVersionId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.daon.rewrite.coverletter.dto;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import org.springframework.data.domain.Page;

import java.util.List;

public record CoverLetterListResponse(
List<CoverLetterListItemResponse> items,
int page,
int size,
long totalItems,
int totalPages
) {
public static CoverLetterListResponse from(Page<CoverLetter> pageResult, int requestedPage, int requestedSize) {
return new CoverLetterListResponse(
pageResult.getContent()
.stream()
.map(CoverLetterListItemResponse::from)
.toList(),
requestedPage,
requestedSize,
pageResult.getTotalElements(),
pageResult.getTotalPages()
);
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/daon/rewrite/coverletter/entity/CoverLetter.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,21 @@ private CoverLetter(String id, String ownerId, CoverLetterStatus status, Instant
public static CoverLetter draft(String id, String ownerId, Instant now) {
return new CoverLetter(id, ownerId, CoverLetterStatus.DRAFT, now);
}

public void fillBasicInfo(String title, String companyName, String positionTitle, String jobPostingUrl, Instant now) {
this.title = title;
this.companyName = companyName;
this.positionTitle = positionTitle;
this.jobPostingUrl = jobPostingUrl;
this.updatedAt = now;
}

public void markDeleted(Instant now) {
this.deletedAt = now;
this.updatedAt = now;
}

public void setLatestReviewVersionId(String latestReviewVersionId) {
this.latestReviewVersionId = latestReviewVersionId;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
package com.daon.rewrite.coverletter.repository;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CoverLetterRepository extends JpaRepository<CoverLetter, String> {

Page<CoverLetter> findByOwnerIdAndDeletedAtIsNull(String ownerId, Pageable pageable);

Page<CoverLetter> findByOwnerIdAndStatusAndDeletedAtIsNull(
String ownerId,
CoverLetterStatus status,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
import com.daon.rewrite.auth.CurrentUser;
import com.daon.rewrite.auth.CurrentUserProvider;
import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.repository.CoverLetterRepository;
import com.daon.rewrite.global.exception.BusinessException;
import com.daon.rewrite.global.exception.ErrorCode;
import com.daon.rewrite.global.util.IdGenerator;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -17,6 +24,7 @@
public class CoverLetterService {

private static final String COVER_LETTER_ID_PREFIX = "cl";
private static final int MAX_LIST_SIZE = 9;

private final CurrentUserProvider currentUserProvider;
private final CoverLetterRepository coverLetterRepository;
Expand All @@ -34,4 +42,32 @@ public CoverLetter createDraft() {

return coverLetterRepository.save(draft);
}

@Transactional(readOnly = true)
public Page<CoverLetter> findMyCoverLetters(int page, int size, CoverLetterStatus status) {
validateListQuery(page, size);

CurrentUser currentUser = currentUserProvider.currentUser();
Pageable pageable = PageRequest.of(
page - 1,
size,
Sort.by(Sort.Direction.DESC, "createdAt")
);

if (status == null) {
return coverLetterRepository.findByOwnerIdAndDeletedAtIsNull(currentUser.id(), pageable);
}

return coverLetterRepository.findByOwnerIdAndStatusAndDeletedAtIsNull(
currentUser.id(),
status,
pageable
);
}

private void validateListQuery(int page, int size) {
if (page < 1 || size < 1 || size > MAX_LIST_SIZE) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

@RestControllerAdvice
public class GlobalExceptionHandler {
Expand Down Expand Up @@ -42,6 +43,18 @@ public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(Metho
));
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException e
) {
return ResponseEntity
.status(ErrorCode.VALIDATION_ERROR.getStatus())
.body(ErrorResponse.of(
ErrorCode.VALIDATION_ERROR.getCode(),
ErrorCode.VALIDATION_ERROR.getMessage()
));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
return ResponseEntity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
package com.daon.rewrite.coverletter.controller;

import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.service.CoverLetterService;
import com.daon.rewrite.global.exception.BusinessException;
import com.daon.rewrite.global.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.time.Instant;
import java.util.List;

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -40,4 +48,72 @@ void createDraftReturnsCreatedDraft() throws Exception {
.andExpect(jsonPath("$.status").value("DRAFT"))
.andExpect(jsonPath("$.createdAt").value("2026-06-20T14:00:00"));
}

@Test
void findMyCoverLettersReturnsPagedItems() throws Exception {
CoverLetter coverLetter = CoverLetter.draft(
"cl_1",
"user_1",
Instant.parse("2026-06-20T05:00:00Z")
);
coverLetter.fillBasicInfo(
"2026 상반기 백엔드 개발자 자기소개서",
"Rewrite Corp",
"백엔드 개발자",
null,
Instant.parse("2026-06-20T05:00:00Z")
);
coverLetter.setLatestReviewVersionId("rv_1");
Page<CoverLetter> page = new PageImpl<>(
List.of(coverLetter),
PageRequest.of(0, 9),
1
);
given(coverLetterService.findMyCoverLetters(1, 9, CoverLetterStatus.DRAFT)).willReturn(page);

mockMvc.perform(get("/cover-letters")
.param("page", "1")
.param("size", "9")
.param("status", "DRAFT"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].id").value("cl_1"))
.andExpect(jsonPath("$.items[0].title").value("2026 상반기 백엔드 개발자 자기소개서"))
.andExpect(jsonPath("$.items[0].companyName").value("Rewrite Corp"))
.andExpect(jsonPath("$.items[0].positionTitle").value("백엔드 개발자"))
.andExpect(jsonPath("$.items[0].status").value("DRAFT"))
.andExpect(jsonPath("$.items[0].createdAt").value("2026-06-20T14:00:00"))
.andExpect(jsonPath("$.items[0].latestReviewVersionId").value("rv_1"))
.andExpect(jsonPath("$.page").value(1))
.andExpect(jsonPath("$.size").value(9))
.andExpect(jsonPath("$.totalItems").value(1))
.andExpect(jsonPath("$.totalPages").value(1));
}

@Test
void findMyCoverLettersUsesDefaultPageAndSize() throws Exception {
given(coverLetterService.findMyCoverLetters(1, 9, null))
.willReturn(Page.empty(PageRequest.of(0, 9)));

mockMvc.perform(get("/cover-letters"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.page").value(1))
.andExpect(jsonPath("$.size").value(9));
}

@Test
void findMyCoverLettersRejectsInvalidPage() throws Exception {
given(coverLetterService.findMyCoverLetters(0, 9, null))
.willThrow(new BusinessException(ErrorCode.VALIDATION_ERROR));

mockMvc.perform(get("/cover-letters").param("page", "0"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR"));
}

@Test
void findMyCoverLettersRejectsInvalidStatus() throws Exception {
mockMvc.perform(get("/cover-letters").param("status", "UNKNOWN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR"));
}
}
Loading
Loading