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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ out/

# Secrets
secrets/
.env
.env.*
!.env.example

# OS metadata
.DS_Store

# IDE / agent 로컬 설정
.claude/
.cursor/

# Superpowers plans (로컬 작업 문서)
docs/superpowers/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,7 @@ class AfterPartyCommandService(
cohortId: CohortId,
): Int {
val now = Instant.now()
val memberRoleType =
memberAccessService.getRoleType(
memberId = memberId,
cohortValue = cohortQueryService.getCohort(cohortId).value,
)
val memberRoleType = memberAccessService.getRoleType(memberId = memberId)
val memberAuthorityId = legacyAuthorityIdForInviteTag(memberRoleType) ?: return 0

val afterParties: List<AfterParty> =
Expand Down Expand Up @@ -300,7 +296,7 @@ class AfterPartyCommandService(
when (roleType) {
RoleType.Deeper -> DEEPER_AUTHORITY_ID
RoleType.Organizer -> ORGANIZER_AUTHORITY_ID
RoleType.Core, RoleType.Guest -> throw InviteTagNameNotFoundException(tagName)
RoleType.Master, RoleType.Core, RoleType.Guest -> throw InviteTagNameNotFoundException(tagName)
},
)

Expand Down Expand Up @@ -332,13 +328,21 @@ class AfterPartyCommandService(
roleType: RoleType,
): String {
val latestCohort = cohortQueryService.getCohort(CohortId(cohortId))
return "${latestCohort.value}기 ${roleType.aliases.first()}"
return "${latestCohort.value}기 ${roleLabel(roleType)}"
}

private fun roleLabel(roleType: RoleType): String = when (roleType) {
RoleType.Organizer -> "운영진"
RoleType.Deeper -> "디퍼"
RoleType.Core -> "코어"
RoleType.Master -> "마스터"
RoleType.Guest -> "게스트"
}

private fun legacyAuthorityIdForInviteTag(roleType: RoleType): Long? =
when (roleType) {
RoleType.Deeper -> DEEPER_AUTHORITY_ID
RoleType.Organizer -> ORGANIZER_AUTHORITY_ID
RoleType.Core, RoleType.Guest -> null
RoleType.Master, RoleType.Core, RoleType.Guest -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import core.application.authorization.presentation.request.UpdateMemberRoleReque
import core.application.member.application.service.MemberQueryService
import core.application.member.application.service.role.MemberRoleService
import core.domain.authorization.vo.RoleType
import core.domain.cohort.vo.CohortId
import core.domain.member.vo.MemberId
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
Expand All @@ -19,25 +20,10 @@ class RoleCommandService(
request: UpdateMemberRoleRequest,
) {
memberQueryService.getMemberById(memberId)

val cohortValue = normalizeCohortValue(request.cohort)
require(cohortValue.all(Char::isDigit)) {
"cohort must be numeric (e.g. 17 or 17기)"
val roleType = RoleType.fromCode(request.roleType)
require(roleType == RoleType.Core || roleType == RoleType.Organizer || roleType == RoleType.Deeper) {
"roleType must be CORE, ORGANIZER or DEEPER"
}
val roleType =
if (request.isAdmin) {
RoleType.Organizer
} else {
RoleType.Deeper
}
val roleName = "${cohortValue}기 ${roleType.aliases.first()}"

memberRoleService.replaceCohortRoleByName(
memberId = memberId,
roleName = roleName,
cohortRolePrefix = "${cohortValue}기 ",
)
memberRoleService.replaceCohortRole(memberId, roleType, CohortId(request.cohortId))
}

private fun normalizeCohortValue(cohort: String): String = cohort.trim().removeSuffix("기")
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package core.application.authorization.application.service

import core.application.cohort.application.service.CohortQueryService
import core.application.member.application.service.role.CurrentCohortRoleResolver
import core.domain.authorization.aggregate.Role
import core.domain.authorization.port.inbound.RoleQueryUseCase
import core.domain.authorization.port.outbound.RolePersistencePort
import core.domain.authorization.vo.RoleDisplayName
import core.domain.authorization.vo.RoleType
import core.domain.member.port.inbound.MemberQueryUseCase
import core.domain.cohort.vo.CohortId
import core.domain.member.port.outbound.MemberRolePersistencePort
import core.domain.member.vo.MemberId
import org.springframework.stereotype.Service
Expand All @@ -14,48 +16,38 @@ import org.springframework.stereotype.Service
class RoleQueryService(
private val rolePersistencePort: RolePersistencePort,
private val memberRolePersistencePort: MemberRolePersistencePort,
private val currentCohortRoleResolver: CurrentCohortRoleResolver,
private val cohortQueryService: CohortQueryService,
) : RoleQueryUseCase {
override fun getAllRoles(): List<Role> {
val latestCohortValue = cohortQueryService.getLatestCohortValue()
return getRolesByCohort(latestCohortValue)
}
override fun getAllRoles(): List<Role> = getRolesByCohort("")

override fun getRolesByCohort(cohort: String): List<Role> {
val cohortValue = normalizeCohortValue(cohort)
return rolePersistencePort
.findAll()
.filter { role ->
val roleType = RoleType.from(role.name)
when (roleType) {
RoleType.Deeper, RoleType.Organizer -> role.name.startsWith("${cohortValue}기 ")
else -> true
}
}
val canonicalNames = setOf(RoleType.Core.code, RoleType.Organizer.code, RoleType.Deeper.code, RoleType.Guest.code)
return rolePersistencePort.findAll().filter { it.name in canonicalNames }
}

override fun getRoleNamesByMemberId(memberId: MemberId): List<String> =
memberRolePersistencePort.findRoleNamesByMemberId(memberId.value)

override fun getRoleNamesByMemberIds(memberIds: List<MemberId>): Map<MemberId, List<String>> {
val roleNamesByMemberId =
memberRolePersistencePort.findRoleNamesByMemberIds(
memberIds.map { it.value },
)
return memberIds.associateWith { memberId -> roleNamesByMemberId[memberId.value].orEmpty() }
}
memberRolePersistencePort
.findActiveRoleAssignmentsByMemberId(memberId.value)
.map { assignment -> RoleDisplayName.of(assignment.roleName, resolveCohortValue(assignment.cohortId)) }
.distinct()

override fun getRoleNamesByMemberIds(memberIds: List<MemberId>): Map<MemberId, List<String>> =
memberIds.associateWith { memberId -> getRoleNamesByMemberId(memberId) }

private fun resolveCohortValue(cohortId: CohortId?): Long? =
cohortId?.value?.let { runCatching { cohortQueryService.getCohort(cohortId).value.toLong() }.getOrNull() }

override fun getRolesByExternalId(externalId: String): List<String> =
rolePersistencePort.findAllByMemberExternalId(externalId).ifEmpty { listOf("GUEST") }
rolePersistencePort.findAllByMemberExternalId(externalId).ifEmpty { listOf(RoleType.Guest.code) }

override fun getPermissionsByMemberId(memberId: MemberId): List<String> {
val effectiveRoleNames = currentCohortRoleResolver.filterEffectiveRolesForMember(memberId)
return rolePersistencePort.findAllPermissionsByMemberIdAndRoleNames(
memberId = memberId,
roleNames = memberRolePersistencePort.findRoleNamesByMemberId(memberId.value),
roleNames = effectiveRoleNames,
)
}

override fun findIdByName(roleName: String): Long = rolePersistencePort.findIdByName(roleName)

private fun normalizeCohortValue(cohort: String): String = cohort.trim().removeSuffix("기")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,11 @@ import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Pattern

data class UpdateMemberRoleRequest(
@field:NotNull
@field:Schema(
description = "운영진 여부. true면 해당 기수 운영진, false면 해당 기수 디퍼로 변경합니다.",
example = "true",
requiredMode = Schema.RequiredMode.REQUIRED,
)
val isAdmin: Boolean,
@field:NotBlank
@field:Pattern(regexp = "\\d+기?", message = "cohort must be numeric (e.g. 17 or 17기)")
@field:Schema(
description = "역할을 변경할 기수 값. 17 또는 17기 형식을 허용합니다.",
example = "17",
requiredMode = Schema.RequiredMode.REQUIRED,
)
val cohort: String,
@field:Pattern(regexp = "MASTER|CORE|ORGANIZER|DEEPER|GUEST", message = "roleType must be one of MASTER, CORE, ORGANIZER, DEEPER, GUEST")
@field:Schema(description = "역할 타입", example = "ORGANIZER", requiredMode = Schema.RequiredMode.REQUIRED)
val roleType: String,
@field:NotNull
@field:Schema(description = "역할이 적용될 기수 ID", example = "18", requiredMode = Schema.RequiredMode.REQUIRED)
val cohortId: Long,
)

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,10 @@ class CohortCommandService(
}
cohortPersistencePort.deleteById(cohortId)
}

fun activateCohort(cohortId: CohortId): Cohort {
cohortQueryService.getCohort(cohortId)
cohortPersistencePort.activate(cohortId)
return cohortQueryService.getCohort(cohortId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,48 +13,45 @@ import org.springframework.transaction.annotation.Transactional
class CohortQueryService(
private val cohortPersistencePort: CohortPersistencePort,
) : CohortQueryUseCase {
fun getLatestCohort(): Cohort =
cohortPersistencePort
.findAll()
.maxByOrNull { toNumericCohortValue(it.value) ?: Int.MIN_VALUE }
?.takeIf { isNumericCohortValue(it.value) }
?: throw CohortNotFoundException()
fun getActiveCohort(): Cohort = cohortPersistencePort.findActive() ?: getLatestCohortByMaxValue()

override fun getLatestCohortId(): CohortId =
getLatestCohort().id
?: throw CohortNotFoundException()
fun getLatestCohort(): Cohort = getActiveCohort()

override fun getActiveCohortId(): CohortId = getActiveCohort().id ?: throw CohortNotFoundException()

override fun getActiveCohortValue(): String = getActiveCohort().value

override fun getLatestCohortValue(): String = getLatestCohort().value
override fun getLatestCohortId(): CohortId = getActiveCohortId()

override fun getLatestCohortValue(): String = getActiveCohortValue()

fun getAllCohorts(): List<Cohort> =
cohortPersistencePort
.findAll()
.sortedWith(
compareByDescending<Cohort> { toNumericCohortValue(it.value) ?: Int.MIN_VALUE }
.thenByDescending { it.createdAt ?: 0L },
)
cohortPersistencePort.findAll().sortedWith(
compareByDescending<Cohort> { toNumericCohortValue(it.value) ?: Int.MIN_VALUE }
.thenByDescending { it.createdAt ?: 0L },
)

fun getCohort(cohortId: CohortId): Cohort =
cohortPersistencePort.findById(cohortId)
?: throw CohortNotFoundException()
cohortPersistencePort.findById(cohortId) ?: throw CohortNotFoundException()

fun getPreviousNumericCohortValue(targetValue: String): String? {
val target = toNumericCohortValue(targetValue) ?: return null
return cohortPersistencePort
.findAll()
return cohortPersistencePort.findAll()
.mapNotNull { cohort -> toNumericCohortValue(cohort.value)?.let { it to cohort.value } }
.filter { (numericValue, _) -> numericValue < target }
.maxByOrNull { (numericValue, _) -> numericValue }
?.second
.maxByOrNull { (numericValue, _) -> numericValue }?.second
}

fun isNumericCohortValue(value: String): Boolean = NUMERIC_COHORT_REGEX.matches(value.trim())

private fun getLatestCohortByMaxValue(): Cohort =
cohortPersistencePort.findAll()
.maxByOrNull { toNumericCohortValue(it.value) ?: Int.MIN_VALUE }
?.takeIf { isNumericCohortValue(it.value) }
?: throw CohortNotFoundException()

private fun toNumericCohortValue(value: String): Int? =
value
.trim()
.takeIf { NUMERIC_COHORT_REGEX.matches(it) }
?.toIntOrNull()
value.trim().takeIf { NUMERIC_COHORT_REGEX.matches(it) }?.toIntOrNull()

companion object {
private val NUMERIC_COHORT_REGEX = Regex("\\d+")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,12 @@
package core.application.cohort.application.service

import core.domain.authorization.port.outbound.RolePersistencePort
import core.domain.authorization.vo.RoleType
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class CohortRoleService(
private val rolePersistencePort: RolePersistencePort,
private val cohortQueryService: CohortQueryService,
) {
class CohortRoleService {
fun createLatestCohortRoles(newCohortValue: String) {
val sourceCohortValue = cohortQueryService.getPreviousNumericCohortValue(newCohortValue) ?: return

listOf(RoleType.Deeper, RoleType.Organizer).forEach { roleType ->
val sourceRoleName = buildCohortRoleName(sourceCohortValue, roleType)
if (!rolePersistencePort.existsByName(sourceRoleName)) {
return@forEach
}

val targetRoleName = buildCohortRoleName(newCohortValue, roleType)
if (rolePersistencePort.existsByName(targetRoleName)) {
return@forEach
}

val permissionIds = rolePersistencePort.findPermissionIdsByRoleName(sourceRoleName)
rolePersistencePort.createRoleWithPermissions(
roleName = targetRoleName,
permissionIds = permissionIds,
)
}
// Phase 1: canonical roles(CORE/ORGANIZER/DEEPER/GUEST) are fixed. Per-cohort role rows are no longer created.
}

fun buildCohortRoleName(
cohortValue: String,
roleType: RoleType,
): String = "${cohortValue}기 ${roleType.aliases.first()}"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package core.application.cohort.presentation.controller

import core.application.cohort.presentation.response.CohortNumberResponse
import core.application.common.exception.CustomResponse
import core.domain.cohort.vo.CohortId
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag

@Tag(name = "Cohort Admin", description = "활성 기수 관리 API")
interface CohortAdminApi {
@ApiResponse(responseCode = "200", description = "활성 기수 조회 성공")
@Operation(summary = "활성 기수 조회", description = "현재 is_active=true인 기수를 조회합니다.")
fun getActiveCohort(): CustomResponse<CohortNumberResponse>

@ApiResponse(responseCode = "200", description = "활성 기수 전환 성공")
@Operation(summary = "활성 기수 전환", description = "지정 기수를 활성 기수로 전환합니다.")
fun activateCohort(cohortId: CohortId): CustomResponse<CohortNumberResponse>
}
Loading
Loading