Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,14 @@ 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

// uq_friend_requester_receiver only blocks a duplicate row in the same direction, so a requester/receiver
// pair can still have two rows (e.g. both sides sent a request before either was accepted). Returning a
// List keeps that a normal case instead of an Optional throwing IncorrectResultSizeDataAccessException.
fun findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(
requesterId1: Long,
receiverId1: Long,
requesterId2: Long,
receiverId2: Long,
): List<Friend>
}
Comment thread
cfcromn marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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 {
if (isSelf) return true
return when (visibility) {
ProfileVisibility.PUBLIC -> true
ProfileVisibility.FRIEND -> friendStatus == FriendStatus.FRIEND
ProfileVisibility.PRIVATE -> false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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)
}

// A requester/receiver pair can have relationship rows in both directions (see FriendRepository), so an
// ACCEPTED row always wins over a stray PENDING row for the same pair.
private fun resolveFriendStatus(viewerId: Long, memberId: Long): FriendStatus {
val relations = friendRepository.findByRequesterIdAndReceiverIdOrRequesterIdAndReceiverId(viewerId, memberId, memberId, viewerId)
if (relations.any { it.status == FriendRequestStatus.ACCEPTED }) return FriendStatus.FRIEND

val pending = relations.firstOrNull { it.status == FriendRequestStatus.PENDING } ?: return FriendStatus.NONE
return if (pending.requester.id == viewerId) FriendStatus.REQUESTED else FriendStatus.RECEIVED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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.AuthException
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 {
// Matches the Auth domain's handling of an invalid token subject (see SignupAuthService,
// WithdrawAuthService) so a client's "401 -> re-login" rule doesn't need a /member/me exception.
val member = memberRepository.findById(memberId).orElseThrow { AuthException(ErrorCode.UNAUTHORIZED) }
if (member.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER)
return MyMemberResponse.from(member)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.AuthException
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 { AuthException(ErrorCode.UNAUTHORIZED) }
if (member.withdrawnAt != null) throw BusinessException(ErrorCode.WITHDRAWN_MEMBER)
// Without this gate, an OAuth-logged-in member who never called /auth/signup could set only a
// nickname here and skip the name/gender/birthYear requirements SignupAuthService enforces.
if (member.nickname == null) throw BusinessException(ErrorCode.SIGNUP_REQUIRED)

request.name?.let {
if (it.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT)
member.name = it.trim()
}
request.nickname?.let { raw ->
if (raw.isBlank()) throw BusinessException(ErrorCode.INVALID_INPUT)
val nickname = raw.trim()
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.trim().ifBlank { null } }
request.bio?.let { member.bio = it.trim().ifBlank { null } }
request.profileVisibility?.let { member.profileVisibility = it }

val saved = try {
memberRepository.saveAndFlush(member)
} catch (e: DataIntegrityViolationException) {
// The only unique constraint reachable in this transaction today is uq_member_nickname, but only
// translate to a 409 when a nickname change was actually requested so an unrelated future
// constraint doesn't get misreported as a nickname conflict.
if (request.nickname != null) throw BusinessException(ErrorCode.NICKNAME_ALREADY_EXISTS)
throw e
}
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/members")
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,38 @@
package team.cklob.mudda.domain.member.presentation.request

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Pattern
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,

// Blank is allowed through here so the service layer's empty-string-to-null clearing still works;
// only an actually non-blank, non-http(s) value (e.g. javascript:, data:, file:) is rejected.
@field:Pattern(regexp = "^\\s*$|^https?://\\S+$", message = "profileImageUrl must be blank or an http(s) URL")
@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,
) {
// Add new fields to this comparison too, or an all-null request for the new field would silently pass.
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,30 @@
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 nickname: String?,
val gender: Gender?,
val birthYear: Int?,
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),
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 @@ -12,6 +12,9 @@ enum class ErrorCode(val status: HttpStatus, val code: String, val message: Stri
OAUTH_PROVIDER_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "A006", "This OAuth provider is not supported yet."),
WITHDRAWN_MEMBER(HttpStatus.FORBIDDEN, "A007", "This account has been withdrawn."),
ALREADY_SIGNED_UP(HttpStatus.CONFLICT, "A008", "This member has already completed signup."),
SIGNUP_REQUIRED(HttpStatus.FORBIDDEN, "A009", "Signup must be completed before this action."),
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 @@ -6,6 +6,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import team.cklob.mudda.global.response.ApiResponse
import org.slf4j.LoggerFactory

Expand All @@ -24,6 +25,11 @@ class GlobalExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(e: HttpMessageNotReadableException) = response(ErrorCode.INVALID_INPUT)

// A path/query variable that fails to convert to its declared type (e.g. a non-numeric member id)
// would otherwise fall through to the catch-all 500 handler below.
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(e: MethodArgumentTypeMismatchException) = response(ErrorCode.INVALID_INPUT)

@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse<Nothing>> {
logger.error("Unexpected exception type: {}", e.javaClass.name)
Expand Down
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
Loading