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
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ public record DeliveryVerificationResponse(
format = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT,
requiredMode = Schema.RequiredMode.REQUIRED
)
LocalDateTime createdAt
LocalDateTime createdAt,
@Schema(
description = ReceivedRecordBoxResponse.APPROVED_AT_DESCRIPTION,
example = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_EXAMPLE,
type = "string",
format = ReceivedRecordBoxResponse.LOCAL_DATE_TIME_FORMAT,
nullable = true,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
LocalDateTime approvedAt
) {
public static DeliveryVerificationResponse from(DeliveryVerification verification) {
return from(verification, Function.identity());
Expand All @@ -51,7 +60,10 @@ public static DeliveryVerificationResponse from(DeliveryVerification verificatio
urlResolver.apply(verification.getDeathCertificateUrl()),
urlResolver.apply(verification.getFamilyRelationCertificateUrl()),
verification.getAdminNote(),
verification.getCreatedAt()
verification.getCreatedAt(),
verification.getStatus() == VerificationStatus.APPROVED
? verification.getUpdatedAt()
: null
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public DeliveryVerification submitVerification(
.familyRelationCertificateUrl(familyRelationCertKey)
.build();

return deliveryVerificationRepository.save(verification);
// createdAt은 flush 때 채워진다. 제출 응답에 null이 나가지 않도록 여기서 flush한다.
return deliveryVerificationRepository.saveAndFlush(verification);
}

public DeliveryVerification getVerificationStatus(Receiver receiver) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ public ApiResponse<UserResponse> getMyProfile(

@Operation(
summary = "프로필 수정 API",
description = "로그인한 사용자의 프로필 정보를 수정합니다."
description = "로그인한 사용자의 프로필 정보를 수정합니다. "
+ "이름은 생략하면 유지하고 공백은 거부합니다. 연락처와 프로필 이미지는 "
+ UserUpdateProfileRequest.OPTIONAL_CLEAR_DESCRIPTION
)
@PatchMapping("/me")
public ApiResponse<UserResponse> updateMyProfile(
Expand Down Expand Up @@ -223,7 +225,8 @@ public ApiResponse<ReceiverDetailResponse> getReceiverDetail(

@Operation(
summary = "수신자 메시지 수정 API",
description = "특정 수신자에게 남길 메시지를 등록하거나 수정합니다."
description = "특정 수신자에게 남길 메시지를 등록하거나 수정합니다. "
+ UserUpdateReceiverMessageRequest.MESSAGE_DESCRIPTION
)
@PatchMapping("/receivers/{receiverId}/message")
public ApiResponse<Void> updateReceiverMessage(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,74 @@
package com.afternote.domain.user.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;

public record UserUpdateProfileRequest(
@Schema(description = "사용자 이름", example = "김소희", nullable = true)
@Getter
String name,
@Schema(description = "프로필 수정 요청")
public class UserUpdateProfileRequest {

@Schema(description = "연락처", example = "01012345678", nullable = true)
@Getter
String phone,
public static final String OPTIONAL_CLEAR_DESCRIPTION =
"필드를 생략하면 기존 값을 유지한다. null 또는 공백 문자열이면 비워 조회 시 null이 된다.";

@Schema(description = "프로필 이미지 URL",
example = "https://cdn.example.com/profile/1.png", nullable = true)
@Getter
String profileImageUrl
) {
@Schema(description = "사용자 이름. 생략하거나 null이면 유지하고, 공백이면 거부한다.", example = "김소희", nullable = true)
@Getter
private String name;

@Schema(
description = "연락처. " + OPTIONAL_CLEAR_DESCRIPTION,
example = "01012345678",
nullable = true,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Getter
private String phone;

@Schema(
description = "프로필 이미지 URL. " + OPTIONAL_CLEAR_DESCRIPTION,
example = "https://cdn.example.com/profile/1.png",
nullable = true,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Getter
private String profileImageUrl;

private boolean phoneSpecified;
private boolean profileImageUrlSpecified;

@JsonProperty("name")
public void setName(String name) {
this.name = name;
}

@JsonProperty("phone")
public void setPhone(String phone) {
this.phoneSpecified = true;
this.phone = blankToNull(phone);
}

@JsonProperty("profileImageUrl")
public void setProfileImageUrl(String profileImageUrl) {
this.profileImageUrlSpecified = true;
this.profileImageUrl = blankToNull(profileImageUrl);
}

@JsonIgnore
@Schema(hidden = true)
public boolean isPhoneSpecified() {
return phoneSpecified;
}

@JsonIgnore
@Schema(hidden = true)
public boolean isProfileImageUrlSpecified() {
return profileImageUrlSpecified;
}

private static String blankToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value;
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
package com.afternote.domain.user.dto;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;

public record UserUpdateReceiverMessageRequest(
@Schema(description = "수신자에게 남길 메시지", example = "사랑하는 딸에게...")
@NotNull
@Getter
String message
) {
@Schema(description = "수신자 메시지 수정 요청")
public class UserUpdateReceiverMessageRequest {

public static final String MESSAGE_DESCRIPTION =
"수신자에게 남길 메시지. 필드를 생략하면 기존 값을 유지한다. "
+ "null 또는 공백 문자열이면 메시지를 비워, 상세 조회의 message는 null이 된다.";

@Schema(
description = MESSAGE_DESCRIPTION,
example = "사랑하는 딸에게...",
nullable = true,
requiredMode = Schema.RequiredMode.NOT_REQUIRED
)
@Getter
private String message;

private boolean messageSpecified;

@JsonProperty("message")
public void setMessage(String message) {
this.messageSpecified = true;
this.message = normalize(message);
}

@JsonIgnore
@Schema(hidden = true)
public boolean isMessageSpecified() {
return messageSpecified;
}

private static String normalize(String message) {
if (message == null || message.isBlank()) {
return null;
}
return message;
}
}
12 changes: 9 additions & 3 deletions src/main/java/com/afternote/domain/user/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,23 @@ public User(String email, String password, String name, String phone,
this.marketingPushEnabled = false;
}

public void updateProfile(String name, String phone, String profileImageUrl) {
public void updateProfile(
String name,
boolean phoneSpecified,
String phone,
boolean profileImageSpecified,
String profileImageUrl
) {
if (name != null) {
if (name.isBlank()) {
throw new CustomException(ErrorCode.INVALID_INPUT_VALUE);
}
this.name = name;
}
if (phone != null && !phone.isBlank()) {
if (phoneSpecified) {
this.phone = phone;
}
if (profileImageUrl != null && !profileImageUrl.isBlank()) {
if (profileImageSpecified) {
this.profileImageUrl = profileImageUrl;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ public UserResponse updateMyProfile(Long userId, UserUpdateProfileRequest reques
User user = findUserById(userId);

String profileImageKey = request.getProfileImageUrl();
if (profileImageKey != null && !profileImageKey.isBlank()) {
if (request.isProfileImageUrlSpecified() && profileImageKey != null) {
profileImageKey = s3Service.promoteManagedMediaKey("profiles", userId, profileImageKey);
}

user.updateProfile(
request.getName(),
request.isPhoneSpecified(),
request.getPhone(),
request.isProfileImageUrlSpecified(),
profileImageKey
);

Expand Down Expand Up @@ -207,6 +209,10 @@ public void updateReceiverMessage(Long userId, Long receiverId, UserUpdateReceiv
userReceiverRepository.findByUserAndReceiverId(user, receiverId)
.orElseThrow(() -> new CustomException(ErrorCode.RECEIVER_NOT_FOUND));

if (!request.isMessageSpecified()) {
return;
}

Receiver receiver = userReceiver.getReceiver();
receiver.updateMessage(request.getMessage());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.afternote.domain.receiver.dto;

import com.afternote.domain.receiver.model.DeliveryVerification;
import com.afternote.domain.receiver.model.VerificationStatus;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.LocalDateTime;

import static org.assertj.core.api.Assertions.assertThat;

class DeliveryVerificationResponseTest {

@Test
@DisplayName("승인 전에는 approvedAt이 null이고 생성 시각은 그대로 내려간다")
void pending_ApprovedAtIsNull() {
DeliveryVerification verification = verification();

DeliveryVerificationResponse response = DeliveryVerificationResponse.from(verification);

assertThat(response.status()).isEqualTo(VerificationStatus.PENDING);
assertThat(response.createdAt()).isEqualTo(LocalDateTime.of(2026, 6, 21, 3, 7, 26));
assertThat(response.approvedAt()).isNull();
}

@Test
@DisplayName("승인 상태의 approvedAt은 기록함과 같이 updatedAt이다")
void approved_ApprovedAtUsesUpdatedAt() {
DeliveryVerification verification = verification();
verification.approve("서류 확인 완료");

DeliveryVerificationResponse response = DeliveryVerificationResponse.from(verification);

assertThat(response.status()).isEqualTo(VerificationStatus.APPROVED);
assertThat(response.approvedAt()).isEqualTo(LocalDateTime.of(2026, 7, 29, 16, 0, 10));
assertThat(response.adminNote()).isEqualTo("서류 확인 완료");
}

@Test
@DisplayName("거절 상태에는 approvedAt을 채우지 않는다")
void rejected_ApprovedAtIsNull() {
DeliveryVerification verification = verification();
verification.reject("서류 불일치");

assertThat(DeliveryVerificationResponse.from(verification).approvedAt()).isNull();
}

private static DeliveryVerification verification() {
DeliveryVerification verification = DeliveryVerification.builder()
.userId(1L)
.receiverId(7L)
.deathCertificateUrl("documents/death")
.familyRelationCertificateUrl("documents/family")
.build();
ReflectionTestUtils.setField(verification, "id", 4L);
ReflectionTestUtils.setField(verification, "createdAt", LocalDateTime.of(2026, 6, 21, 3, 7, 26));
ReflectionTestUtils.setField(verification, "updatedAt", LocalDateTime.of(2026, 7, 29, 16, 0, 10));
return verification;
}
}
Loading
Loading