Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -10,6 +10,7 @@ import team.cklob.mudda.domain.auth.presentation.response.LoginAuthResponse
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.domain.type.OAuthProvider
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import team.cklob.mudda.global.exception.AuthException
import team.cklob.mudda.global.exception.ErrorCode
import team.cklob.mudda.global.security.JwtTokenProvider
Expand Down Expand Up @@ -60,11 +61,10 @@ class LoginAuthService(
email = userInfo.email,
oauthProvider = userInfo.provider,
providerId = userInfo.providerId,
profileVisibility = DEFAULT_PROFILE_VISIBILITY,
profileVisibility = ProfileVisibility.PUBLIC,
)

private companion object {
const val DEFAULT_PROFILE_VISIBILITY = "PUBLIC"
const val WITHDRAWAL_GRACE_PERIOD_DAYS = 30L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ interface FriendRepository : JpaRepository<Friend, Long> {
fun findByRequesterIdOrReceiverId(requesterId: Long, receiverId: Long): List<Friend>
fun findByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Optional<Friend>
fun existsByRequesterIdAndReceiverId(requesterId: Long, receiverId: Long): Boolean

fun findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(
requesterId1: Long,
receiverId1: Long,
requesterId2: Long,
receiverId2: Long,
): Optional<Friend>
}
Comment thread
cfcromn marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package team.cklob.mudda.domain.member.application

import team.cklob.mudda.domain.friend.domain.type.FriendStatus
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility

object ProfileAccessPolicy {
fun canView(visibility: ProfileVisibility, isSelf: Boolean, friendStatus: FriendStatus): Boolean = when {
isSelf -> true
visibility == ProfileVisibility.PUBLIC -> true
visibility == ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND
else -> false
}
Comment thread
cfcromn marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package team.cklob.mudda.domain.member.application.impl

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.friend.domain.repository.FriendRepository
import team.cklob.mudda.domain.friend.domain.type.FriendRequestStatus
import team.cklob.mudda.domain.friend.domain.type.FriendStatus
import team.cklob.mudda.domain.member.application.ProfileAccessPolicy
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.presentation.response.MemberProfileResponse
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class GetMemberProfileService(
private val memberRepository: MemberRepository,
private val friendRepository: FriendRepository,
) {
@Transactional(readOnly = true)
fun execute(viewerId: Long, memberId: Long): MemberProfileResponse {
val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
if (member.withdrawnAt != null || member.nickname == null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)

val isSelf = viewerId == memberId
val friendStatus = if (isSelf) FriendStatus.NONE else resolveFriendStatus(viewerId, memberId)

if (!ProfileAccessPolicy.canView(member.profileVisibility, isSelf, friendStatus)) {
throw BusinessException(ErrorCode.PROFILE_ACCESS_DENIED)
}
Comment thread
cfcromn marked this conversation as resolved.

return MemberProfileResponse.of(member, friendStatus)
}

private fun resolveFriendStatus(viewerId: Long, memberId: Long): FriendStatus {
val friend = friendRepository
.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId)
.orElse(null) ?: return FriendStatus.NONE
Comment thread
cfcromn marked this conversation as resolved.
Outdated

return when (friend.status) {
FriendRequestStatus.ACCEPTED -> FriendStatus.FRIEND
FriendRequestStatus.REJECTED -> FriendStatus.NONE
FriendRequestStatus.PENDING -> if (friend.requester.id == viewerId) FriendStatus.REQUESTED else FriendStatus.RECEIVED
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package team.cklob.mudda.domain.member.application.impl

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class GetMyMemberService(
private val memberRepository: MemberRepository,
) {
@Transactional(readOnly = true)
fun execute(memberId: Long): MyMemberResponse {
val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)
Comment thread
cfcromn marked this conversation as resolved.
Outdated
return MyMemberResponse.from(member)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package team.cklob.mudda.domain.member.application.impl

import org.springframework.dao.DataIntegrityViolationException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest
import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode

@Service
class UpdateMyMemberService(
private val memberRepository: MemberRepository,
) {
@Transactional
fun execute(memberId: Long, request: UpdateMyMemberRequest): MyMemberResponse {
if (request.isEmpty()) throw BusinessException(ErrorCode.INVALID_INPUT)

val member = memberRepository.findById(memberId).orElseThrow { BusinessException(ErrorCode.MEMBER_NOT_FOUND) }
if (member.withdrawnAt != null) throw BusinessException(ErrorCode.MEMBER_NOT_FOUND)
Comment thread
cfcromn marked this conversation as resolved.
Outdated

request.name?.let {
if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT)
member.name = it
}
request.nickname?.let { nickname ->
if (nickname.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT)
if (nickname != member.nickname && memberRepository.existsByNickname(nickname)) {
throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS)
}
member.nickname = nickname
}
request.gender?.let { member.gender = it }
request.birthYear?.let { member.birthYear = it }
request.profileImageUrl?.let { member.profileImageUrl = it.ifEmpty { null } }
request.bio?.let { member.bio = it.ifEmpty { null } }
Comment thread
cfcromn marked this conversation as resolved.
Outdated
request.profileVisibility?.let { member.profileVisibility = it }

val saved = try {
memberRepository.saveAndFlush(member)
} catch (e: DataIntegrityViolationException) {
throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS)
}
Comment thread
cfcromn marked this conversation as resolved.

return MyMemberResponse.from(saved)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import jakarta.persistence.Id
import jakarta.persistence.Table
import team.cklob.mudda.domain.member.domain.type.Gender
import team.cklob.mudda.domain.member.domain.type.OAuthProvider
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import team.cklob.mudda.global.common.entity.BaseTimeEntity
import java.time.LocalDateTime

Expand Down Expand Up @@ -47,8 +48,9 @@ class Member(
@Column(length = 100)
var bio: String? = null,

@Enumerated(EnumType.STRING)
@Column(name = "profile_visibility", nullable = false, length = 20)
var profileVisibility: String,
var profileVisibility: ProfileVisibility,
Comment thread
cfcromn marked this conversation as resolved.

@Column(name = "withdrawn_at")
var withdrawnAt: LocalDateTime? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package team.cklob.mudda.domain.member.domain.type

enum class ProfileVisibility {
PUBLIC,
FRIEND,
PRIVATE,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package team.cklob.mudda.domain.member.presentation.controller

import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import team.cklob.mudda.domain.member.application.impl.GetMemberProfileService
import team.cklob.mudda.domain.member.application.impl.GetMyMemberService
import team.cklob.mudda.domain.member.application.impl.UpdateMyMemberService
import team.cklob.mudda.domain.member.presentation.request.UpdateMyMemberRequest
import team.cklob.mudda.domain.member.presentation.response.MemberProfileResponse
import team.cklob.mudda.domain.member.presentation.response.MyMemberResponse
import team.cklob.mudda.global.response.ApiResponse
import team.cklob.mudda.global.security.LoginUser

@RestController
@RequestMapping("/api/v1/member")
class MemberController(
private val getMyMemberService: GetMyMemberService,
private val updateMyMemberService: UpdateMyMemberService,
private val getMemberProfileService: GetMemberProfileService,
) {
@GetMapping("/me")
fun getMe(@LoginUser memberId: Long): ResponseEntity<ApiResponse<MyMemberResponse>> =
ResponseEntity.ok(ApiResponse.success(getMyMemberService.execute(memberId)))

@PatchMapping("/me")
fun updateMe(
@LoginUser memberId: Long,
@Valid @RequestBody request: UpdateMyMemberRequest,
): ResponseEntity<ApiResponse<MyMemberResponse>> = ResponseEntity.ok(ApiResponse.success(updateMyMemberService.execute(memberId, request)))

@GetMapping("/{memberId}")
fun getProfile(
@LoginUser viewerId: Long,
@PathVariable memberId: Long,
Comment thread
cfcromn marked this conversation as resolved.
): ResponseEntity<ApiResponse<MemberProfileResponse>> = ResponseEntity.ok(ApiResponse.success(getMemberProfileService.execute(viewerId, memberId)))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package team.cklob.mudda.domain.member.presentation.request

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Size
import team.cklob.mudda.domain.member.domain.type.Gender
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility

data class UpdateMyMemberRequest(
@field:Size(max = 30)
val name: String? = null,

@field:Size(max = 30)
val nickname: String? = null,

val gender: Gender? = null,

@field:Min(1900)
@field:Max(2100)
val birthYear: Int? = null,

@field:Size(max = 255)
val profileImageUrl: String? = null,
Comment thread
cfcromn marked this conversation as resolved.

@field:Size(max = 100)
val bio: String? = null,

val profileVisibility: ProfileVisibility? = null,
) {
fun isEmpty(): Boolean =
name == null && nickname == null && gender == null && birthYear == null &&
profileImageUrl == null && bio == null && profileVisibility == null
Comment thread
cfcromn marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package team.cklob.mudda.domain.member.presentation.response

import team.cklob.mudda.domain.friend.domain.type.FriendStatus
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.type.Gender
import java.time.LocalDateTime

data class MemberProfileResponse(
val memberId: Long,
val name: String?,
val nickname: String?,
val gender: Gender?,
val birthYear: Int?,
Comment thread
cfcromn marked this conversation as resolved.
Outdated
val profileImageUrl: String?,
val bio: String?,
val friendStatus: FriendStatus,
val createdAt: LocalDateTime,
) {
companion object {
fun of(member: Member, friendStatus: FriendStatus) = MemberProfileResponse(
memberId = requireNotNull(member.id),
name = member.name,
nickname = member.nickname,
gender = member.gender,
birthYear = member.birthYear,
profileImageUrl = member.profileImageUrl,
bio = member.bio,
friendStatus = friendStatus,
createdAt = member.createdAt,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package team.cklob.mudda.domain.member.presentation.response

import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.type.Gender
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import java.time.LocalDateTime

data class MyMemberResponse(
val memberId: Long,
val name: String?,
val nickname: String?,
val gender: Gender?,
val birthYear: Int?,
val profileImageUrl: String?,
val bio: String?,
val profileVisibility: ProfileVisibility,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
) {
companion object {
fun from(member: Member) = MyMemberResponse(
memberId = requireNotNull(member.id),
name = member.name,
nickname = member.nickname,
gender = member.gender,
birthYear = member.birthYear,
profileImageUrl = member.profileImageUrl,
bio = member.bio,
profileVisibility = member.profileVisibility,
createdAt = member.createdAt,
updatedAt = member.updatedAt,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri
WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "A007", "This account has been withdrawn."),
ALREADY_SIGNED_UP(HttpStatus.CONFLICT, "A008", "This member has already completed signup."),
NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "M001", "Nickname already exists."),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M002", "Member not found."),
PROFILE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "M003", "You do not have access to this profile."),
CAPSULE_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "Time capsule not found."),
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import team.cklob.mudda.domain.auth.presentation.request.LoginAuthRequest
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.domain.type.OAuthProvider
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import team.cklob.mudda.global.exception.AuthException
import team.cklob.mudda.global.security.JwtTokenProvider
import java.time.LocalDateTime
Expand All @@ -32,7 +33,7 @@ class LoginAuthServiceTest {

private fun member(id: Long, withdrawnAt: LocalDateTime? = null, providerId: String = "google-sub-1") = Member(
email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = providerId,
profileVisibility = "PUBLIC", withdrawnAt = withdrawnAt, id = id,
profileVisibility = ProfileVisibility.PUBLIC, withdrawnAt = withdrawnAt, id = id,
)

@Test fun `issues tokens for an existing fully signed up member`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.domain.type.Gender
import team.cklob.mudda.domain.member.domain.type.OAuthProvider
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import team.cklob.mudda.global.exception.BusinessException
import team.cklob.mudda.global.exception.ErrorCode
import java.util.Optional
Expand All @@ -21,7 +22,7 @@ class SignupAuthServiceTest {
private val request = SignupAuthRequest(name = "name", nickname = "nickname", gender = Gender.MALE, birthYear = 2000)

private fun incompleteMember(): Member =
Member(email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = "PUBLIC", id = 1L)
Member(email = "user@example.com", oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = ProfileVisibility.PUBLIC, id = 1L)

@Test fun `completes signup for an incomplete member`() {
val member = incompleteMember()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import team.cklob.mudda.domain.auth.application.RefreshTokenStore
import team.cklob.mudda.domain.member.domain.entity.Member
import team.cklob.mudda.domain.member.domain.repository.MemberRepository
import team.cklob.mudda.domain.member.domain.type.OAuthProvider
import team.cklob.mudda.domain.member.domain.type.ProfileVisibility
import team.cklob.mudda.global.security.AccessTokenBlacklist
import team.cklob.mudda.global.security.JwtTokenProvider
import java.time.Duration
Expand All @@ -26,7 +27,7 @@ class WithdrawAuthServiceTest {
@Test fun `soft deletes and anonymizes the member, then revokes tokens`() {
val member = Member(
name = "name", nickname = "nickname", email = "user@example.com",
oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = "PUBLIC", id = 1L,
oauthProvider = OAuthProvider.GOOGLE, providerId = "google-sub-1", profileVisibility = ProfileVisibility.PUBLIC, id = 1L,
)
every { memberRepository.findById(1L) } returns Optional.of(member)
every { jwtTokenProvider.getJti("access-token") } returns "jti-1"
Expand Down
Loading