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 @@ -58,7 +58,7 @@ API 설계 결정과 트레이드오프는 `../decisions/README.md`를 함께
| API-007 | GET | `/cover-letters` | Implemented | REQ-003 | 내 자기소개서 목록 |
| API-008 | POST | `/cover-letters` | Implemented | REQ-003 | 자기소개서 초안 생성 |
| API-009 | PUT | `/cover-letters/{coverLetterId}/basic-info` | Implemented | REQ-004 | 등록 step1 저장 |
| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Planned | REQ-004 | 등록 step2 저장 |
| API-010 | PUT | `/cover-letters/{coverLetterId}/preferences` | Implemented | REQ-004 | 등록 step2 저장 |
| API-011 | PUT | `/cover-letters/{coverLetterId}/questions` | Planned | REQ-004 | 등록 step3 저장 |
| API-012 | GET | `/cover-letters/{coverLetterId}` | Planned | REQ-003 | 자기소개서 상세 |
| API-013 | DELETE | `/cover-letters/{coverLetterId}` | Implemented | REQ-003 | 자기소개서 soft delete |
Expand Down
12 changes: 12 additions & 0 deletions docs/api/cover-letters.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ preferences: 필수, trim 후 Unicode code point 기준 1~3000자

서버는 `preferences`의 앞뒤 공백을 제거한 뒤 길이를 검증하고, 공백이 제거된 값을 저장한다. trim 후 빈 문자열이면 `VALIDATION_ERROR`를 반환한다.

Conflict Response:

```json
{
"error": {
"code": "COVER_LETTER_NOT_DRAFT",
"message": "제출된 자기소개서의 원본 정보는 수정할 수 없습니다.",
"details": []
}
}
```

Response:

```json
Expand Down
3 changes: 1 addition & 2 deletions docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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), [#28](https://github.com/Rewrite-Team/Rewrite-BE/issues/28), [#30](https://github.com/Rewrite-Team/Rewrite-BE/issues/30), [PR #15](https://github.com/Rewrite-Team/Rewrite-BE/pull/15) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `CoverLetterRepositoryTest`, `./gradlew test`, `./gradlew check` | API-007 현재 사용자 자기소개서 목록 조회, API-008 자기소개서 초안 생성, API-013 soft delete 계약 구현됨. 진행 중 Job cancel은 `llm_jobs` 구현 이후 연결 |
| REQ-004 | 자기소개서 등록 step 저장 | In Progress | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장 계약 구현됨. API-010/API-011은 후속 slice 후보 기준으로 분리 진행 |
| REQ-004 | 자기소개서 등록 step 저장 | In Progress | High | API-009, API-010, API-011 | [#32](https://github.com/Rewrite-Team/Rewrite-BE/issues/32), [#34](https://github.com/Rewrite-Team/Rewrite-BE/issues/34) | `CoverLetterServiceTest`, `CoverLetterControllerTest`, `GlobalExceptionHandlerTest`, `./gradlew test`, `./gradlew check` | API-009 등록 step1 기본 정보 저장, API-010 등록 step2 채용 우대사항 저장 계약 구현됨. API-011은 후속 slice 후보 기준으로 분리 진행 |
| 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 이후 진행 권장 |
| REQ-007 | DB/JPA 전환 | Implemented | High | API-008 내부 persistence, persistence 내부 변경 | [#22](https://github.com/Rewrite-Team/Rewrite-BE/issues/22), [#24](https://github.com/Rewrite-Team/Rewrite-BE/issues/24), [#25](https://github.com/Rewrite-Team/Rewrite-BE/issues/25), [PR #26](https://github.com/Rewrite-Team/Rewrite-BE/pull/26) | `CoverLetterRepositoryTest`, `CoverLetterServiceTest`, `CoverLetterControllerTest`, `./gradlew test`, `./gradlew check` | API-008 공개 계약은 유지하고 `cover_letters` persistence를 DB/JPA로 전환. H2 file 로컬 DB, H2 in-memory 테스트 DB 사용. Flyway는 별도 이슈로 분리 |
Expand Down Expand Up @@ -59,7 +59,6 @@ API 계약과 API별 상태는 `docs/api/README.md`와 `docs/api/` 하위 도메
| Candidate | Related REQ | Related APIs | Suggested Scope |
|---|---|---|---|
| 자기소개서 상세 조회 | REQ-003 | API-012 | DB/JPA repository 기반 owner 검증, deletedAt 제외, 질문/최신 Job 요약 포함 skeleton, service/web/repository test |
| 등록 step2 저장 | REQ-004 | API-010 | preferences replace, trim, empty validation, DRAFT 상태 검증, service/web test |
| 등록 step3 저장 | REQ-004 | API-011 | questions replace, order 재부여, 글자 수 검증, service/web test |

## Update Rules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.daon.rewrite.coverletter.dto.DeleteCoverLetterResponse;
import com.daon.rewrite.coverletter.dto.SaveBasicInfoRequest;
import com.daon.rewrite.coverletter.dto.SaveBasicInfoResponse;
import com.daon.rewrite.coverletter.dto.SavePreferencesRequest;
import com.daon.rewrite.coverletter.dto.SavePreferencesResponse;
import com.daon.rewrite.coverletter.entity.CoverLetter;
import com.daon.rewrite.coverletter.entity.CoverLetterStatus;
import com.daon.rewrite.coverletter.service.CoverLetterService;
Expand Down Expand Up @@ -62,4 +64,16 @@ public SaveBasicInfoResponse saveBasicInfo(
);
return SaveBasicInfoResponse.from(result);
}

@PutMapping("/cover-letters/{coverLetterId}/preferences")
public SavePreferencesResponse savePreferences(
@PathVariable String coverLetterId,
@RequestBody SavePreferencesRequest request
) {
CoverLetter result = coverLetterService.savePreferences(
coverLetterId,
request.preferences()
);
return SavePreferencesResponse.from(result);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.daon.rewrite.coverletter.dto;

public record SavePreferencesRequest(
String preferences
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 SavePreferencesResponse(
String id,
String preferences,
CoverLetterStatus status,
LocalDateTime updatedAt
) {
private static final ZoneId API_ZONE = ZoneId.of("Asia/Seoul");

public static SavePreferencesResponse from(CoverLetter coverLetter) {
return new SavePreferencesResponse(
coverLetter.getId(),
coverLetter.getPreferences(),
coverLetter.getStatus(),
LocalDateTime.ofInstant(coverLetter.getUpdatedAt(), API_ZONE)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ public void fillBasicInfo(String title, String companyName, String positionTitle
this.updatedAt = now;
}

public void fillPreferences(String preferences, Instant now) {
this.preferences = preferences;
this.updatedAt = now;
}

public void markDeleted(Instant now) {
this.deletedAt = now;
this.updatedAt = now;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class CoverLetterService {
private static final int MAX_COMPANY_NAME_LENGTH = 30;
private static final int MAX_POSITION_TITLE_LENGTH = 30;
private static final int MAX_JOB_POSTING_URL_LENGTH = 500;
private static final int MAX_PREFERENCES_LENGTH = 3000;

private final CurrentUserProvider currentUserProvider;
private final CoverLetterRepository coverLetterRepository;
Expand Down Expand Up @@ -119,6 +120,23 @@ public CoverLetter saveBasicInfo(
return coverLetter;
}

@Transactional
public CoverLetter savePreferences(String coverLetterId, String preferences) {
CurrentUser currentUser = currentUserProvider.currentUser();
CoverLetter coverLetter = coverLetterRepository
.findByIdAndOwnerIdAndDeletedAtIsNull(coverLetterId, currentUser.id())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));

if (coverLetter.getStatus() != CoverLetterStatus.DRAFT) {
throw new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT);
}

String normalizedPreferences = validateAndNormalizePreferences(preferences);
coverLetter.fillPreferences(normalizedPreferences, Instant.now(clock));

return coverLetter;
}

private void validateListQuery(int page, int size) {
if (page < 1 || size < 1 || size > MAX_LIST_SIZE) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR);
Expand Down Expand Up @@ -189,6 +207,24 @@ private String normalizeRequiredText(
return normalized;
}

private String validateAndNormalizePreferences(String preferences) {
List<ErrorResponse.ErrorDetail> details = new ArrayList<>();
String normalizedPreferences = normalizeRequiredText(
"preferences",
preferences,
MAX_PREFERENCES_LENGTH,
"채용 우대사항은 필수입니다.",
"채용 우대사항은 최대 3000자까지 입력할 수 있습니다.",
details
);

if (!details.isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, details);
}

return normalizedPreferences;
}

private String normalizeOptionalJobPostingUrl(
String value,
List<ErrorResponse.ErrorDetail> details
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,95 @@ void saveBasicInfoReturnsCoverLetterNotDraft() throws Exception {
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT"));
}

@Test
void savePreferencesReturnsUpdatedPreferences() throws Exception {
CoverLetter updated = CoverLetter.draft(
"cl_preferences",
"user_1",
Instant.parse("2026-06-20T05:00:00Z")
);
updated.fillPreferences(
"Spring Boot 경험, 대용량 트래픽 처리 경험 우대",
Instant.parse("2026-06-20T05:08:00Z")
);
given(coverLetterService.savePreferences(
"cl_preferences",
" Spring Boot 경험, 대용량 트래픽 처리 경험 우대 "
)).willReturn(updated);

mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_preferences")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"preferences": " Spring Boot 경험, 대용량 트래픽 처리 경험 우대 "
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("cl_preferences"))
.andExpect(jsonPath("$.preferences").value("Spring Boot 경험, 대용량 트래픽 처리 경험 우대"))
.andExpect(jsonPath("$.status").value("DRAFT"))
.andExpect(jsonPath("$.updatedAt").value("2026-06-20T14:08:00"));
}

@Test
void savePreferencesReturnsValidationDetails() throws Exception {
given(coverLetterService.savePreferences(
"cl_preferences",
" "
)).willThrow(new BusinessException(
ErrorCode.VALIDATION_ERROR,
List.of(new com.daon.rewrite.global.response.ErrorResponse.ErrorDetail(
"preferences",
"채용 우대사항은 필수입니다."
))
));

mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_preferences")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"preferences": " "
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.error.details[0].field").value("preferences"));
}

@Test
void savePreferencesReturnsNotFound() throws Exception {
given(coverLetterService.savePreferences(
"cl_missing",
"Spring Boot 경험"
)).willThrow(new BusinessException(ErrorCode.NOT_FOUND));

mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_missing")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"preferences": "Spring Boot 경험"
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error.code").value("NOT_FOUND"));
}

@Test
void savePreferencesReturnsCoverLetterNotDraft() throws Exception {
given(coverLetterService.savePreferences(
"cl_reviewing",
"Spring Boot 경험"
)).willThrow(new BusinessException(ErrorCode.COVER_LETTER_NOT_DRAFT));

mockMvc.perform(put("/cover-letters/{coverLetterId}/preferences", "cl_reviewing")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"preferences": "Spring Boot 경험"
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error.code").value("COVER_LETTER_NOT_DRAFT"));
}
}
Loading
Loading