Skip to content
Open
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 @@ -109,7 +109,7 @@ class EmailService(
val endsAt: Instant?,
val location: String?,
val description: String?,
val hostEmail: String,
val hostEmail: String?,
)

data class DemotionEmailData(
Expand Down Expand Up @@ -240,7 +240,7 @@ class EmailService(
.replace("{eventTitle}", formatEventTitle(data.eventTitle))
.replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-"))
.replace("{location}", formatLocation(data.location))
.replace("{hostEmail}", data.hostEmail)
.replace("{hostEmail}", data.hostEmail ?: "탈퇴유저")
.replace("{description}", formatDescription(data.description))

sendHtmlEmail(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ data class EventDto(
val waitlistEnabled: Boolean,
val registrationStartsAt: Long?,
val registrationEndsAt: Long,
val createdBy: Long,
val createdBy: Long?,
val createdAt: Long?,
val updatedAt: Long?,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ data class EventInfo(

data class CreatorInfo(
val name: String,
val email: String,
val email: String?,
val profileImage: String?,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Event(
@Column("registration_ends_at")
var registrationEndsAt: Instant,
@Column("created_by")
var createdBy: Long,
var createdBy: Long?,
@CreatedDate
@Column("created_at")
var createdAt: Instant? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import java.time.Instant
interface EventRepository : ListCrudRepository<Event, Long> {
fun findByPublicId(publicId: String): Event?

fun findByCreatedBy(createdBy: Long): List<Event>

fun findByCreatedByAndCreatedAtIsNotNullOrderByCreatedAtDesc(
createdBy: Long,
pageable: Pageable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class EventService(

val userIdsToFetch = listOfNotNull(event.createdBy, requesterId).distinct()
val usersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! }
val creatorUser = usersById[event.createdBy] ?: throw EventNotFoundException()
val creatorUser = event.createdBy?.let { usersById[it] }

val myReg =
if (requesterId == null) {
Expand Down Expand Up @@ -230,9 +230,9 @@ class EventService(
),
creator =
CreatorInfo(
name = creatorUser.name,
email = creatorUser.email,
profileImage = creatorUser.profileImage?.let { imageService.presignedGetUrl(it) },
name = creatorUser?.name ?: "탈퇴유저",
email = creatorUser?.email,
profileImage = creatorUser?.profileImage?.let { imageService.presignedGetUrl(it) },
),
viewer = viewer,
capabilities = capabilities,
Expand Down Expand Up @@ -391,7 +391,7 @@ class EventService(
)

// 이메일 데이터 구성 (삭제 전에 user 정보 조회)
val hostUser = userRepository.findById(event.createdBy).orElse(null)
val hostUser = event.createdBy?.let { userRepository.findById(it).orElse(null) }
val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct()
val usersById = userRepository.findAllById(userIds).associateBy { it.id!! }

Expand All @@ -408,7 +408,7 @@ class EventService(
endsAt = event.endsAt,
location = event.location,
description = event.description,
hostEmail = hostUser?.email ?: "",
hostEmail = hostUser?.email,
)
}

Expand All @@ -423,6 +423,54 @@ class EventService(
}
}

/**
* 유저 탈퇴 시 호출. 시작 전(now < startsAt) 이벤트들을 일괄 삭제하고
* 참여자/대기자에게 익명화된 주최자 정보로 취소 메일을 발송한다.
* 소유권 체크는 호출 측에서 보장.
*/
@Transactional
fun deleteEventsForWithdrawal(events: List<Event>) {
if (events.isEmpty()) return
val eventIds = events.map { it.id!! }.sorted()

// 락 획득 (ID 오름차순으로 deadlock 방지)
eventIds.forEach { eventLockRepository.lockById(it) }

val eventsById = events.associateBy { it.id!! }
val registrations =
registrationRepository.findByEventIdInAndStatusIn(
eventIds = eventIds,
statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED),
)
val userIds = registrations.mapNotNull { it.userId }.distinct()
val usersById = userRepository.findAllById(userIds).associateBy { it.id!! }

val emailDataList =
registrations.mapNotNull { reg ->
val event = eventsById[reg.eventId] ?: return@mapNotNull null
val user = reg.userId?.let { usersById[it] }
val toEmail = user?.email ?: reg.guestEmail
if (toEmail.isNullOrBlank()) return@mapNotNull null
EmailService.EventCancellationEmailData(
toEmail = toEmail,
name = user?.name ?: reg.guestName ?: "참여자",
eventTitle = event.title,
startsAt = event.startsAt,
endsAt = event.endsAt,
location = event.location,
description = event.description,
hostEmail = null,
)
}

registrationRepository.deleteByEventIdIn(eventIds)
eventRepository.deleteAllById(eventIds)

afterCommit {
emailDataList.forEach { emailService.sendEventCancellationEmail(it) }
}
}

private fun getEventByPublicId(publicId: String): Event =
eventRepository.findByPublicId(publicId)
?: throw EventNotFoundException()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ interface RegistrationRepository :

fun deleteByEventId(eventId: Long)

fun deleteByEventIdIn(eventIds: Collection<Long>)

fun findByUserIdAndStatusIn(
userId: Long,
statuses: Collection<RegistrationStatus>,
): List<Registration>

fun findByEventIdInAndStatusIn(
eventIds: Collection<Long>,
statuses: Collection<RegistrationStatus>,
): List<Registration>

fun findByRegistrationPublicId(registrationPublicId: String): Registration?

@Query(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,35 @@ RegistrationService(
}
}

/**
* 유저 탈퇴 시 호출. 신청기간/소유권 체크 없이 다수 등록을 일괄 삭제하고
* 확정 등록이 있던 이벤트에 대해서만 대기자 승격을 트리거한다.
* 탈퇴자에게 취소 메일은 발송하지 않는다. 대기자 승격 메일은 reconcileWaitlist 내부에서 처리됨.
*/
@Transactional
fun cancelRegistrationsForWithdrawal(registrations: List<Registration>) {
if (registrations.isEmpty()) return

val byEventId = registrations.groupBy { it.eventId }
val eventIds = byEventId.keys.sorted()

// 1. 이벤트 락 (ID 오름차순으로 deadlock 방지)
eventIds.forEach { eventLockRepository.lockById(it) }

// 2. CONFIRMED 등록이 포함된 이벤트만 대기자 승격 필요
val eventsToReconcile =
byEventId
.filterValues { regs -> regs.any { it.status == RegistrationStatus.CONFIRMED } }
.keys
.sorted()

// 3. 배치 삭제
registrationRepository.deleteAllById(registrations.map { it.id!! })

// 4. 필요한 이벤트만 승격
eventsToReconcile.forEach { reconcileWaitlist(it) }
}

private fun toResponseStatus(status: RegistrationStatus): RegistrationStatusResponse =
when (status) {
RegistrationStatus.HOST -> RegistrationStatusResponse.CONFIRMED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse
import com.wafflestudio.spring2025.domain.user.model.User
import com.wafflestudio.spring2025.domain.user.service.UserService
import org.springframework.http.ResponseEntity
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.RequestBody
Expand Down Expand Up @@ -41,4 +42,12 @@ class UserController(
)
return ResponseEntity.ok(userService.me(user))
}

@DeleteMapping("/me")
fun deleteMe(
@LoggedInUser user: User,
): ResponseEntity<Void> {
userService.deleteMe(user = user)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ enum class UserErrorCode(
title = "이메일은 변경할 수 없습니다.",
message = "이메일 주소는 변경이 불가능합니다.",
),
NO_SUCH_USER(
httpStatusCode = HttpStatus.NOT_FOUND,
title = "존재하지 않는 회원입니다.",
message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다.",
),
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ interface UserIdentityRepository : ListCrudRepository<UserIdentity, Long> {
fun findByUserId(userId: Long): List<UserIdentity>

fun findByProviderUserId(providerId: String): List<UserIdentity>

fun deleteByUserId(userId: Long)
}
Loading
Loading