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 @@ -244,10 +244,6 @@ internal object AfternoteEditorFormMapper {
memorialMediaUrls: MemorialMediaUrls,
baseline: AfternoteEditorSnapshot,
): UpdateAfternoteInput {
if (type == AfternoteType.ESTATE) {
// placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그.
error("Unimplemented type cannot be saved: $type")
}
val current =
buildEditorSnapshot(
type = type,
Expand All @@ -256,43 +252,89 @@ internal object AfternoteEditorFormMapper {
playlistSongs = playlistSongs,
memorialMediaUrls = memorialMediaUrls,
)
return UpdateAfternoteInput(
type = type,
title = current.title.takeIf { it.trim() != baseline.title.trim() },
// 빈 문자열을 걷어내지 않는다 — 서버는 actions 원소를 검증 없이 저장하므로 `[""]` 가
// 실제로 남아 있을 수 있고, 그 행을 지운 저장이 양쪽 정규화로 상쇄되면 삭제가 사라진다.
processingMethods = current.processingMethods.takeIf { it != baseline.processingMethods },
leaveMessageBlocks = current.leaveMessageBlocks.takeIf { it != baseline.leaveMessageBlocks },
credentials = diffCredentials(current, baseline),
// 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다.
receiverIds = current.receiverIds?.takeIf { it.sorted() != baseline.receiverIds?.sorted() },
memorial = diffMemorial(current, baseline),
)
val common =
UpdateAfternoteInput(
type = type,
title = current.title.takeIf { it.trim() != baseline.title.trim() },
leaveMessageBlocks = current.leaveMessageBlocks.takeIf { it != baseline.leaveMessageBlocks },
)
// 카테고리마다 그 카테고리가 가진 슬롯만 견준다. 없는 슬롯은 기본값(null)이라 요청에서 빠진다.
return when (current) {
is SocialNetworkSnapshot -> {
val origin = baseline.requireSameCategory<SocialNetworkSnapshot>()
common.copy(
processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods),
credentials = changedCredentials(current.credentials, origin.credentials),
receiverIds = changedReceivers(current.receiverIds, origin.receiverIds),
)
}

is BusinessSnapshot -> {
val origin = baseline.requireSameCategory<BusinessSnapshot>()
common.copy(
processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods),
credentials = changedCredentials(current.credentials, origin.credentials),
receiverIds = changedReceivers(current.receiverIds, origin.receiverIds),
)
}

is GallerySnapshot -> {
val origin = baseline.requireSameCategory<GallerySnapshot>()
common.copy(
processingMethods = changedProcessingMethods(current.processingMethods, origin.processingMethods),
receiverIds = changedReceivers(current.receiverIds, origin.receiverIds),
)
}

// 추억 노트 수정 페이로드는 수신자를 싣지 않는다.
is MemorialSnapshot -> {
common.copy(memorial = changedMemorial(current, baseline.requireSameCategory<MemorialSnapshot>()))
}
}
}

/**
* 저장 카테고리와 기준 카테고리는 같아야 한다. 수정 화면은 카테고리를 바꿀 수 없고, 호출부는 기준을
* 만든 상세의 카테고리로 저장한다. 어긋났다면 슬롯을 견줄 수 없으므로 호출자 버그다.
*/
private inline fun <reified T : AfternoteEditorSnapshot> AfternoteEditorSnapshot.requireSameCategory(): T =
checkNotNull(this as? T) { "수정 기준 스냅샷의 카테고리가 저장 카테고리와 다릅니다" }

/**
* 빈 문자열을 걷어내지 않는다. 서버는 actions 원소를 검증 없이 저장하므로 `[""]` 가 실제로 남아
* 있을 수 있고, 그 행을 지운 저장이 양쪽 정규화로 상쇄되면 삭제가 사라진다.
*/
private fun changedProcessingMethods(
current: List<String>,
baseline: List<String>,
): List<String>? = current.takeIf { it != baseline }

/** 순서는 뜻을 갖지 않으므로 정렬해 견주고, 실을 때는 폼 순서 그대로 보낸다. */
private fun changedReceivers(
current: List<Long>,
baseline: List<Long>,
): List<Long>? = current.takeIf { it.sorted() != baseline.sorted() }

/** 계정 정보는 서버가 id·비밀번호를 독립으로 갱신하므로 슬롯별로 재고, 둘 다 그대로면 통째로 뺀다. */
private fun diffCredentials(
current: AfternoteEditorSnapshot,
baseline: AfternoteEditorSnapshot,
private fun changedCredentials(
current: CredentialsSnapshot,
baseline: CredentialsSnapshot,
): AfternoteAccountCredentials? {
val id = current.credentialsId.takeIf { it != baseline.credentialsId }
val password = current.credentialsPassword.takeIf { it != baseline.credentialsPassword }
val id = current.id.takeIf { it != baseline.id }
val password = current.password.takeIf { it != baseline.password }
return if (id == null && password == null) null else AfternoteAccountCredentials(id = id, password = password)
}

/** 플레이리스트는 슬롯 셋을 따로 재고, 하나도 안 바뀌었으면 `playlist` 키 자체를 내보내지 않는다. */
private fun diffMemorial(
current: AfternoteEditorSnapshot,
baseline: AfternoteEditorSnapshot,
private fun changedMemorial(
current: MemorialSnapshot,
baseline: MemorialSnapshot,
): MemorialPatchInput? {
if (current.type != AfternoteType.MEMORIAL) return null
val patch =
MemorialPatchInput(
memorialPhotoUrl =
FieldPatch.changedOrUnchanged(current.memorialPhotoUrl, baseline.memorialPhotoUrl),
memorialPhotoUrl = FieldPatch.changedOrUnchanged(current.photoUrl, baseline.photoUrl),
songs = current.songs.takeIf { it != baseline.songs },
memorialVideo =
FieldPatch.changedOrUnchanged(current.memorialVideo, baseline.memorialVideo),
memorialVideo = FieldPatch.changedOrUnchanged(current.video, baseline.video),
)
return patch.takeUnless { it.isUnchanged }
}
Expand All @@ -305,39 +347,57 @@ internal object AfternoteEditorFormMapper {
* 화면 표시용으로 값을 한 번 가공하기 때문이다 — 비교 기준은 가공 전 원본이어야 한다.
*/
fun buildUpdateBaseline(detail: Detail): AfternoteEditorSnapshot {
val content = detail.content
val memorial = content as? DetailContent.Memorial
return AfternoteEditorSnapshot(
type = content.type,
title = detail.serviceName,
processingMethods =
when (content) {
is DetailContent.SocialNetwork -> content.processingMethods
is DetailContent.Business -> content.processingMethods
is DetailContent.Gallery -> content.processingMethods
is DetailContent.Memorial, DetailContent.Estate -> null
},
leaveMessageBlocks = detail.leaveMessageBlocks.normalizedForDiff(),
credentialsId = content.detailCredentials()?.id?.ifBlank { null },
credentialsPassword = content.detailCredentials()?.password?.ifBlank { null },
// 추억 노트 수정 페이로드는 수신자를 싣지 않으므로 기준도 같은 자리를 비워 둔다.
receiverIds =
when (content) {
is DetailContent.Memorial, DetailContent.Estate -> {
null
}

is DetailContent.SocialNetwork, is DetailContent.Business, is DetailContent.Gallery -> {
detail.receivers.map { it.receiverId }
}
},
memorialPhotoUrl = memorial?.media?.photoUrl?.ifBlank { null },
memorialVideo = memorial?.media?.toVideoPayload(),
songs =
memorial?.songs?.map { song ->
MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.coverUrl)
},
)
val title = detail.serviceName
val leaveMessageBlocks = detail.leaveMessageBlocks.normalizedForDiff()
val receiverIds = detail.receivers.map { it.receiverId }
return when (val content = detail.content) {
is DetailContent.SocialNetwork -> {
SocialNetworkSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
credentials = content.credentials.toCredentialsSnapshot(),
processingMethods = content.processingMethods,
receiverIds = receiverIds,
)
}

is DetailContent.Business -> {
BusinessSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
credentials = content.credentials.toCredentialsSnapshot(),
processingMethods = content.processingMethods,
receiverIds = receiverIds,
)
}

is DetailContent.Gallery -> {
GallerySnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
processingMethods = content.processingMethods,
receiverIds = receiverIds,
)
}

is DetailContent.Memorial -> {
MemorialSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
photoUrl = content.media.photoUrl?.ifBlank { null },
video = content.media.toVideoPayload(),
songs =
content.songs.map { song ->
MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.coverUrl)
},
)
}

// 저장은 막히지만 수정 진입은 되므로 기준은 만든다.
DetailContent.Estate -> {
EstateSnapshot(title = title, leaveMessageBlocks = leaveMessageBlocks)
}
}
}

/** 현재 폼을 기준 스냅샷과 **같은 어휘**로 옮긴다 — 그래야 슬롯끼리 견줄 수 있다. */
Expand All @@ -347,36 +407,64 @@ internal object AfternoteEditorFormMapper {
selectedReceiverIds: List<Long>,
playlistSongs: List<Song>,
memorialMediaUrls: MemorialMediaUrls,
): AfternoteEditorSnapshot {
val isMemorial = type == AfternoteType.MEMORIAL
val hasCredentials = type == AfternoteType.SOCIAL_NETWORK || type == AfternoteType.BUSINESS
return AfternoteEditorSnapshot(
type = type,
title = payload.serviceName,
processingMethods = if (isMemorial) null else payload.processingMethods,
leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks().normalizedForDiff(),
credentialsId = if (hasCredentials) payload.accountId.ifBlank { null } else null,
credentialsPassword = if (hasCredentials) payload.password.ifBlank { null } else null,
receiverIds = if (isMemorial) null else selectedReceiverIds,
memorialPhotoUrl = if (isMemorial) memorialMediaUrls.memorialPhotoUrl?.ifBlank { null } else null,
memorialVideo = if (isMemorial) memorialMediaUrls.toVideoPayload() else null,
songs =
if (isMemorial) {
playlistSongs.map { song ->
MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl)
}
} else {
null
},
)
}
): SavableEditorSnapshot {
val title = payload.serviceName
val leaveMessageBlocks = payload.messageBlocks.toLeaveMessageBlocks().normalizedForDiff()
return when (type) {
AfternoteType.SOCIAL_NETWORK -> {
SocialNetworkSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
credentials = payload.toCredentialsSnapshot(),
processingMethods = payload.processingMethods,
receiverIds = selectedReceiverIds,
)
}

private fun DetailContent.detailCredentials(): DetailCredentials? =
when (this) {
is DetailContent.SocialNetwork -> credentials
is DetailContent.Business -> credentials
is DetailContent.Gallery, is DetailContent.Memorial, DetailContent.Estate -> null
AfternoteType.BUSINESS -> {
BusinessSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
credentials = payload.toCredentialsSnapshot(),
processingMethods = payload.processingMethods,
receiverIds = selectedReceiverIds,
)
}

AfternoteType.GALLERY_AND_FILES -> {
GallerySnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
processingMethods = payload.processingMethods,
receiverIds = selectedReceiverIds,
)
}

AfternoteType.MEMORIAL -> {
MemorialSnapshot(
title = title,
leaveMessageBlocks = leaveMessageBlocks,
photoUrl = memorialMediaUrls.memorialPhotoUrl?.ifBlank { null },
video = memorialMediaUrls.toVideoPayload(),
songs =
playlistSongs.map { song ->
MemorialSongPayload(title = song.title, artist = song.artist, coverUrl = song.albumCoverUrl)
},
)
}

// placeholder 카테고리는 Validator 에서 차단됨. 도달 시 호출자 버그.
AfternoteType.ESTATE -> {
error("Unimplemented type cannot be saved: $type")
}
}
}

private fun DetailCredentials.toCredentialsSnapshot() =
CredentialsSnapshot(id = id.ifBlank { null }, password = password.ifBlank { null })

private fun RegisterAfternotePayload.toCredentialsSnapshot() =
CredentialsSnapshot(id = accountId.ifBlank { null }, password = password.ifBlank { null })

private fun MemorialMedia.toVideoPayload(): MemorialVideoPayload? =
videoUrl?.ifBlank { null }?.let { url ->
Expand All @@ -393,20 +481,57 @@ internal object AfternoteEditorFormMapper {
* 현재 폼과 서버 원본을 견주기 위한 **같은 어휘의 스냅샷** (#1617).
*
* 「무엇을 보낼까」가 아니라 「무엇이 달라졌나」만 판정하는 자료다. 그래서 wire 표현(키 생략·명시적
* null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리에 없는 슬롯은 `null` 로 비워
* 양쪽이 「둘 다 없음」으로 맞아떨어지게 한다.
* null·빈 배열)을 담지 않고, 슬롯마다 의미값 하나씩만 든다. 카테고리마다 그 카테고리가 가진 슬롯만
* 들며, 분류는 [DetailContent] 와 같다.
*/
internal data class AfternoteEditorSnapshot(
val type: AfternoteType,
val title: String,
val processingMethods: List<String>?,
val leaveMessageBlocks: List<LeaveMessageBlock>,
val credentialsId: String?,
val credentialsPassword: String?,
val receiverIds: List<Long>?,
val memorialPhotoUrl: String?,
val memorialVideo: MemorialVideoPayload?,
val songs: List<MemorialSongPayload>?,
internal sealed interface AfternoteEditorSnapshot {
val title: String
val leaveMessageBlocks: List<LeaveMessageBlock>
}

/** 수정 저장까지 가는 카테고리. [EstateSnapshot] 은 기준으로만 존재한다. */
private sealed interface SavableEditorSnapshot : AfternoteEditorSnapshot

private data class SocialNetworkSnapshot(
override val title: String,
override val leaveMessageBlocks: List<LeaveMessageBlock>,
val credentials: CredentialsSnapshot,
val processingMethods: List<String>,
val receiverIds: List<Long>,
) : SavableEditorSnapshot

private data class BusinessSnapshot(
override val title: String,
override val leaveMessageBlocks: List<LeaveMessageBlock>,
val credentials: CredentialsSnapshot,
val processingMethods: List<String>,
val receiverIds: List<Long>,
) : SavableEditorSnapshot

private data class GallerySnapshot(
override val title: String,
override val leaveMessageBlocks: List<LeaveMessageBlock>,
val processingMethods: List<String>,
val receiverIds: List<Long>,
) : SavableEditorSnapshot

private data class MemorialSnapshot(
override val title: String,
override val leaveMessageBlocks: List<LeaveMessageBlock>,
val photoUrl: String?,
val video: MemorialVideoPayload?,
val songs: List<MemorialSongPayload>,
) : SavableEditorSnapshot

private data class EstateSnapshot(
override val title: String,
override val leaveMessageBlocks: List<LeaveMessageBlock>,
) : AfternoteEditorSnapshot

/** 빈 칸은 `null` 로 맞춰 둔다. 서버의 빈 값과 폼의 빈 입력이 같은 값으로 견줘지게. */
private data class CredentialsSnapshot(
val id: String?,
val password: String?,
)

/**
Expand Down
Loading
Loading