diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java new file mode 100644 index 00000000..72452351 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java @@ -0,0 +1,56 @@ +package com.slatto.domain.project.controller; + +import com.slatto.domain.project.dto.ProjectMemberDetailResponse; +import com.slatto.domain.project.dto.ProjectMemberListResponse; +import com.slatto.domain.project.service.ProjectMemberService; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Project Member", description = "프로젝트 멤버 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/projects/{projectId}/members") +public class ProjectMemberController { + + private static final String CURRENT_USER_ID_HEADER = "X-USER-ID"; + + private final ProjectMemberService projectMemberService; + + @Operation(summary = "프로젝트 멤버 목록 조회") + @GetMapping + public ApiResponse getProjectMembers( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId + ) { + ProjectMemberListResponse response = projectMemberService.getProjectMembers( + projectId, + currentUserId + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 멤버 상세 조회") + @GetMapping("/{memberId}") + public ApiResponse getProjectMember( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long memberId + ) { + ProjectMemberDetailResponse response = projectMemberService.getProjectMember( + projectId, + memberId, + currentUserId + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } +} diff --git a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java index 41dfe70f..8b2b7cd1 100644 --- a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java +++ b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java @@ -26,7 +26,6 @@ public Project toProject(Users ownerUser, ProjectCreateRequest request) { ownerUser, request.getTitle(), request.getType(), - request.getCustomTypeName(), request.getLengthType(), request.getDescription(), request.getEndDate(), @@ -67,7 +66,6 @@ public ProjectListResponse.ProjectSummary toSummary( .id(project.getId()) .title(project.getTitle()) .type(project.getType()) - .customTypeName(project.getCustomTypeName()) .lengthType(project.getLengthType()) .status(project.getStatus()) .kind(project.getKind()) @@ -94,7 +92,6 @@ public ProjectDetailResponse toDetailResponse( .id(project.getId()) .title(project.getTitle()) .type(project.getType()) - .customTypeName(project.getCustomTypeName()) .lengthType(project.getLengthType()) .description(project.getDescription()) .startDate(project.getStartDate()) diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java index a88c1e0a..e551536a 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java @@ -26,9 +26,6 @@ public class ProjectCreateRequest { @NotNull(message = "프로젝트 유형은 필수입니다.") private CategoryName type; - @Size(max = 100, message = "직접 입력한 프로젝트 유형은 최대 100자까지 입력할 수 있습니다.") - private String customTypeName; - @NotNull(message = "영상 길이는 필수입니다.") private LengthType lengthType; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java index 8d5c0802..a527f79e 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java @@ -23,8 +23,6 @@ public class ProjectDetailResponse { private CategoryName type; - private String customTypeName; - private LengthType lengthType; private String description; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java index dce1728e..bace11ec 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java @@ -31,8 +31,6 @@ public static class ProjectSummary { private CategoryName type; - private String customTypeName; - private LengthType lengthType; private ProjectStatus status; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectMemberDetailResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectMemberDetailResponse.java new file mode 100644 index 00000000..79f4129f --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectMemberDetailResponse.java @@ -0,0 +1,32 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.project.enums.Permission; +import com.slatto.domain.user.enums.RoleName; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class ProjectMemberDetailResponse { + + private Long memberId; + + private Long userId; + + private String nickname; + + private String email; + + private String profileImageUrl; + + private String bio; + + private Permission permission; + + private List roleNames; + + private LocalDateTime joinedAt; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectMemberListResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectMemberListResponse.java new file mode 100644 index 00000000..3e9cf163 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectMemberListResponse.java @@ -0,0 +1,37 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.project.enums.Permission; +import com.slatto.domain.user.enums.RoleName; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class ProjectMemberListResponse { + + private List items; + + private Long memberCount; + + @Getter + @Builder + public static class MemberSummary { + + private Long memberId; + + private Long userId; + + private String nickname; + + private String profileImageUrl; + + private Permission permission; + + private List roleNames; + + private LocalDateTime joinedAt; + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java index bb1bd6d7..7b24de0f 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java @@ -24,9 +24,6 @@ public class ProjectUpdateRequest { @NotNull(message = "프로젝트 유형은 필수입니다.") private CategoryName type; - @Size(max = 100, message = "직접 입력한 프로젝트 유형은 최대 100자까지 입력할 수 있습니다.") - private String customTypeName; - @NotNull(message = "영상 길이는 필수입니다.") private LengthType lengthType; diff --git a/src/main/java/com/slatto/domain/project/entity/Project.java b/src/main/java/com/slatto/domain/project/entity/Project.java index 611dc917..24c302ff 100644 --- a/src/main/java/com/slatto/domain/project/entity/Project.java +++ b/src/main/java/com/slatto/domain/project/entity/Project.java @@ -40,9 +40,6 @@ public class Project extends BaseEntity { @Column(name = "type", nullable = true) private CategoryName type; - @Column(name = "custom_type_name", nullable = true, length = 100) - private String customTypeName; - @Enumerated(EnumType.STRING) @Column(name = "length_type", nullable = true) private LengthType lengthType; @@ -74,7 +71,6 @@ private Project( Users ownerUser, String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -87,7 +83,6 @@ private Project( this.ownerUser = ownerUser; this.title = title; this.type = type; - this.customTypeName = customTypeName; this.lengthType = lengthType; this.description = description; this.startDate = startDate; @@ -101,7 +96,6 @@ public static Project create( Users ownerUser, String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -112,7 +106,6 @@ public static Project create( ownerUser, title, type, - customTypeName, lengthType, description, endDate, @@ -124,7 +117,6 @@ public static Project create( public void updateInfo( String title, CategoryName type, - String customTypeName, LengthType lengthType, String description, LocalDate endDate, @@ -135,7 +127,6 @@ public void updateInfo( this.title = title; this.type = type; - this.customTypeName = customTypeName; this.lengthType = lengthType; this.description = description; this.endDate = endDate; diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java index 3db384e6..e3ecc32a 100644 --- a/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java +++ b/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java @@ -18,6 +18,29 @@ public interface ProjectMemberRepository extends JpaRepository findAllActiveMembersByProjectId(@Param("projectId") Long projectId); + + @Query(""" + select pm + from ProjectMember pm + join fetch pm.user u + where pm.project.id = :projectId + and pm.id = :memberId + and pm.leftAt is null + """) + Optional findActiveMemberByProjectIdAndMemberId( + @Param("projectId") Long projectId, + @Param("memberId") Long memberId + ); + @Query(""" select pm from ProjectMember pm diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java index 1f488618..31a8a46d 100644 --- a/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java +++ b/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java @@ -2,6 +2,8 @@ import com.slatto.domain.project.entity.ProjectUserRole; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Collection; import java.util.List; @@ -11,4 +13,26 @@ public interface ProjectUserRoleRepository extends JpaRepository findAllByProjectMemberId(Long projectMemberId); List findAllByProjectMemberIdIn(Collection projectMemberIds); + + @Query(""" + select pur + from ProjectUserRole pur + join fetch pur.projectMember pm + where pm.id = :projectMemberId + order by pur.id asc + """) + List findAllByProjectMemberIdOrderByIdAsc( + @Param("projectMemberId") Long projectMemberId + ); + + @Query(""" + select pur + from ProjectUserRole pur + join fetch pur.projectMember pm + where pm.id in :projectMemberIds + order by pm.id asc, pur.id asc + """) + List findAllByProjectMemberIdsOrderByProjectMemberIdAscAndIdAsc( + @Param("projectMemberIds") Collection projectMemberIds + ); } diff --git a/src/main/java/com/slatto/domain/project/service/ProjectAccessValidator.java b/src/main/java/com/slatto/domain/project/service/ProjectAccessValidator.java new file mode 100644 index 00000000..c491c4b6 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/service/ProjectAccessValidator.java @@ -0,0 +1,47 @@ +package com.slatto.domain.project.service; + +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectMemberRepository; +import com.slatto.domain.project.repository.ProjectRepository; +import com.slatto.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectAccessValidator { + + private final ProjectRepository projectRepository; + private final ProjectMemberRepository projectMemberRepository; + + public Project getProjectOrThrow(Long projectId) { + return projectRepository.findByIdAndDeletedAtIsNull(projectId) + .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_NOT_FOUND)); + } + + public ProjectMember getCurrentMemberOrThrow(Long projectId, Long currentUserId) { + return projectMemberRepository.findByProjectIdAndUserIdAndLeftAtIsNull(projectId, currentUserId) + .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED)); + } + + public ProjectMember getCurrentAdminOrThrow(Long projectId, Long currentUserId) { + ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); + validateAdmin(currentMember); + + return currentMember; + } + + public void validateProjectAccess(Long projectId, Long currentUserId) { + getCurrentMemberOrThrow(projectId, currentUserId); + } + + public void validateAdmin(ProjectMember projectMember) { + if (!projectMember.isAdmin()) { + throw new BaseException(ProjectErrorCode.PROJECT_ADMIN_REQUIRED); + } + } +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java b/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java new file mode 100644 index 00000000..04c486a4 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java @@ -0,0 +1,125 @@ +package com.slatto.domain.project.service; + +import com.slatto.domain.project.dto.ProjectMemberDetailResponse; +import com.slatto.domain.project.dto.ProjectMemberListResponse; +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.entity.ProjectUserRole; +import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectMemberRepository; +import com.slatto.domain.project.repository.ProjectUserRoleRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.RoleName; +import com.slatto.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectMemberService { + + private final ProjectMemberRepository projectMemberRepository; + private final ProjectUserRoleRepository projectUserRoleRepository; + private final ProjectAccessValidator projectAccessValidator; + + public ProjectMemberListResponse getProjectMembers(Long projectId, Long currentUserId) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.validateProjectAccess(projectId, currentUserId); + + List projectMembers = projectMemberRepository.findAllActiveMembersByProjectId(projectId); + Map> roleNamesByMemberId = getRoleNamesByMemberId(projectMembers); + + List items = projectMembers.stream() + .map(projectMember -> toMemberSummary( + projectMember, + roleNamesByMemberId.getOrDefault(projectMember.getId(), List.of()) + )) + .toList(); + + return ProjectMemberListResponse.builder() + .items(items) + .memberCount((long) items.size()) + .build(); + } + + public ProjectMemberDetailResponse getProjectMember( + Long projectId, + Long memberId, + Long currentUserId + ) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.validateProjectAccess(projectId, currentUserId); + + ProjectMember projectMember = projectMemberRepository.findActiveMemberByProjectIdAndMemberId( + projectId, + memberId + ) + .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_MEMBER_NOT_FOUND)); + + List roleNames = projectUserRoleRepository.findAllByProjectMemberIdOrderByIdAsc(memberId) + .stream() + .map(ProjectUserRole::getRoleName) + .toList(); + + return toMemberDetailResponse(projectMember, roleNames); + } + + private Map> getRoleNamesByMemberId(List projectMembers) { + List projectMemberIds = projectMembers.stream() + .map(ProjectMember::getId) + .toList(); + + if (projectMemberIds.isEmpty()) { + return Map.of(); + } + + return projectUserRoleRepository + .findAllByProjectMemberIdsOrderByProjectMemberIdAscAndIdAsc(projectMemberIds) + .stream() + .collect(Collectors.groupingBy( + projectUserRole -> projectUserRole.getProjectMember().getId(), + Collectors.mapping(ProjectUserRole::getRoleName, Collectors.toList()) + )); + } + + private ProjectMemberListResponse.MemberSummary toMemberSummary( + ProjectMember projectMember, + List roleNames + ) { + Users user = projectMember.getUser(); + + return ProjectMemberListResponse.MemberSummary.builder() + .memberId(projectMember.getId()) + .userId(user.getId()) + .nickname(user.getNickname()) + .profileImageUrl(user.getProfileImageUrl()) + .permission(projectMember.getPermission()) + .roleNames(roleNames) + .joinedAt(projectMember.getJoinedAt()) + .build(); + } + + private ProjectMemberDetailResponse toMemberDetailResponse( + ProjectMember projectMember, + List roleNames + ) { + Users user = projectMember.getUser(); + + return ProjectMemberDetailResponse.builder() + .memberId(projectMember.getId()) + .userId(user.getId()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .profileImageUrl(user.getProfileImageUrl()) + .bio(user.getBio()) + .permission(projectMember.getPermission()) + .roleNames(roleNames) + .joinedAt(projectMember.getJoinedAt()) + .build(); + } +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectService.java b/src/main/java/com/slatto/domain/project/service/ProjectService.java index 3edaf496..304c1ecc 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -43,6 +43,7 @@ public class ProjectService { private final ProjectUserRoleRepository projectUserRoleRepository; private final UserRepository userRepository; private final ProjectConverter projectConverter; + private final ProjectAccessValidator projectAccessValidator; @Transactional public ProjectResponse createProject(Long ownerUserId, ProjectCreateRequest request) { @@ -99,8 +100,8 @@ public ProjectListResponse getProjects( } public ProjectDetailResponse getProject(Long projectId, Long currentUserId) { - Project project = getProjectOrThrow(projectId); - ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); + Project project = projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); List myRoles = projectUserRoleRepository.findAllByProjectMemberId(currentMember.getId()) .stream() @@ -121,14 +122,12 @@ public ProjectResponse updateProject( Long currentUserId, ProjectUpdateRequest request ) { - Project project = getProjectOrThrow(projectId); - ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); - validateAdmin(currentMember); + Project project = projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.getCurrentAdminOrThrow(projectId, currentUserId); project.updateInfo( request.getTitle(), request.getType(), - request.getCustomTypeName(), request.getLengthType(), request.getDescription(), request.getEndDate(), @@ -145,9 +144,8 @@ public ProjectResponse updateProject( @Transactional public void deleteProject(Long projectId, Long currentUserId) { - Project project = getProjectOrThrow(projectId); - ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); - validateAdmin(currentMember); + Project project = projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.getCurrentAdminOrThrow(projectId, currentUserId); project.delete(); } @@ -170,22 +168,6 @@ private void validateProjectCreationQuota(Long ownerUserId) { } } - private Project getProjectOrThrow(Long projectId) { - return projectRepository.findByIdAndDeletedAtIsNull(projectId) - .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_NOT_FOUND)); - } - - private ProjectMember getCurrentMemberOrThrow(Long projectId, Long currentUserId) { - return projectMemberRepository.findByProjectIdAndUserIdAndLeftAtIsNull(projectId, currentUserId) - .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED)); - } - - private void validateAdmin(ProjectMember projectMember) { - if (!projectMember.isAdmin()) { - throw new BaseException(ProjectErrorCode.PROJECT_ADMIN_REQUIRED); - } - } - private void saveProjectRoles(ProjectMember projectMember, List roleNames) { if (roleNames == null || roleNames.isEmpty()) { return; diff --git a/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java b/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java index 5325e4ea..c7ae38df 100644 --- a/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java +++ b/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java @@ -32,9 +32,6 @@ public class UserPortfolio extends BaseEntity { @Column(name = "type", nullable = false) private CategoryName type; - @Column(name = "custom_type_name", nullable = true, length = 100) - private String customTypeName; - @Enumerated(EnumType.STRING) @Column(name = "kind", nullable = true) private Kind kind; @@ -56,4 +53,4 @@ public class UserPortfolio extends BaseEntity { @Column(name = "deleted_at", nullable = true) private LocalDateTime deletedAt; -} \ No newline at end of file +}