diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index 58f3eef6..d7e4d9ce 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -14,13 +14,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -32,14 +32,12 @@ @RequestMapping("/api/v1/projects") public class ProjectController { - private static final String CURRENT_USER_ID_HEADER = "X-USER-ID"; - private final ProjectService projectService; @Operation(summary = "프로젝트 목록 조회") @GetMapping public ApiResponse getProjects( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @RequestParam(required = false) ProjectStatus status, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "20") int size @@ -53,7 +51,7 @@ public ApiResponse getProjects( @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProject( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @Valid @RequestBody ProjectCreateRequest request ) { ProjectResponse response = projectService.createProject(currentUserId, request); @@ -64,7 +62,7 @@ public ApiResponse createProject( @Operation(summary = "프로젝트 상세 조회") @GetMapping("/{projectId}") public ApiResponse getProject( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId ) { ProjectDetailResponse response = projectService.getProject(projectId, currentUserId); @@ -75,7 +73,7 @@ public ApiResponse getProject( @Operation(summary = "프로젝트 수정") @PatchMapping("/{projectId}") public ApiResponse updateProject( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @Valid @RequestBody ProjectUpdateRequest request ) { @@ -87,7 +85,7 @@ public ApiResponse updateProject( @Operation(summary = "프로젝트 삭제") @DeleteMapping("/{projectId}") public ApiResponse deleteProject( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId ) { projectService.deleteProject(projectId, currentUserId); diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java index d0e7c1ec..76c64998 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectInvitationController.java @@ -1,6 +1,7 @@ package com.slatto.domain.project.controller; import com.slatto.domain.project.dto.ProjectInvitationAcceptResponse; +import com.slatto.domain.project.dto.ProjectInvitationAcceptRequest; import com.slatto.domain.project.dto.ProjectInvitationCreateRequest; import com.slatto.domain.project.dto.ProjectInvitationCreateResponse; import com.slatto.domain.project.dto.ProjectInvitationDetailResponse; @@ -12,11 +13,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -27,15 +28,13 @@ @RequestMapping("/api/v1") public class ProjectInvitationController { - private static final String CURRENT_USER_ID_HEADER = "X-USER-ID"; - private final ProjectInvitationService projectInvitationService; @Operation(summary = "프로젝트 초대 링크 생성") @PostMapping("/projects/{projectId}/invitations") @ResponseStatus(HttpStatus.CREATED) public ApiResponse createInvitation( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @Valid @RequestBody(required = false) ProjectInvitationCreateRequest request ) { @@ -61,12 +60,14 @@ public ApiResponse getInvitation( @Operation(summary = "프로젝트 초대 수락") @PostMapping("/project-invitations/{token}/accept") public ApiResponse acceptInvitation( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, - @PathVariable String token + @AuthenticationPrincipal Long currentUserId, + @PathVariable String token, + @Valid @RequestBody ProjectInvitationAcceptRequest request ) { ProjectInvitationAcceptResponse response = projectInvitationService.acceptInvitation( token, - currentUserId + currentUserId, + request ); return ApiResponse.success(CommonSuccessCode.OK, response); diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java index 457218bd..fc8f218e 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectMemberController.java @@ -10,12 +10,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.PathVariable; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -25,14 +25,12 @@ @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, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId ) { ProjectMemberListResponse response = projectMemberService.getProjectMembers( @@ -46,7 +44,7 @@ public ApiResponse getProjectMembers( @Operation(summary = "프로젝트 나가기") @DeleteMapping("/me") public ApiResponse leaveProject( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId ) { projectMemberService.leaveProject(projectId, currentUserId); @@ -57,7 +55,7 @@ public ApiResponse leaveProject( @Operation(summary = "프로젝트 멤버 상세 조회") @GetMapping("/{memberId}") public ApiResponse getProjectMember( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @PathVariable Long memberId ) { @@ -73,7 +71,7 @@ public ApiResponse getProjectMember( @Operation(summary = "프로젝트 멤버 역할 수정") @PatchMapping("/{memberId}") public ApiResponse updateProjectMemberRoles( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @PathVariable Long memberId, @Valid @RequestBody ProjectMemberUpdateRequest request @@ -91,7 +89,7 @@ public ApiResponse updateProjectMemberRoles( @Operation(summary = "프로젝트 멤버 삭제") @DeleteMapping("/{memberId}") public ApiResponse removeProjectMember( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @PathVariable Long memberId ) { diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java index 51dda540..0123abe7 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -12,13 +12,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; @@ -30,14 +30,12 @@ @RequestMapping("/api/v1/projects/{projectId}/notices") public class ProjectNoticeController { - private static final String CURRENT_USER_ID_HEADER = "X-USER-ID"; - private final ProjectNoticeService projectNoticeService; @Operation(summary = "프로젝트 공지 목록 조회") @GetMapping public ApiResponse getProjectNotices( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @RequestParam(required = false) Long cursor, @RequestParam(defaultValue = "20") int size @@ -56,7 +54,7 @@ public ApiResponse getProjectNotices( @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createProjectNotice( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @Valid @RequestBody ProjectNoticeCreateRequest request ) { @@ -72,7 +70,7 @@ public ApiResponse createProjectNotice( @Operation(summary = "프로젝트 공지 수정") @PatchMapping("/{noticeId}") public ApiResponse updateProjectNotice( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @PathVariable Long noticeId, @Valid @RequestBody ProjectNoticeUpdateRequest request @@ -90,7 +88,7 @@ public ApiResponse updateProjectNotice( @Operation(summary = "프로젝트 공지 삭제") @DeleteMapping("/{noticeId}") public ApiResponse deleteProjectNotice( - @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @AuthenticationPrincipal Long currentUserId, @PathVariable Long projectId, @PathVariable Long noticeId ) { diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptRequest.java new file mode 100644 index 00000000..52c530dc --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptRequest.java @@ -0,0 +1,18 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.user.enums.RoleName; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectInvitationAcceptRequest { + + @NotEmpty(message = "프로젝트에서의 역할은 1개 이상 선택해야 합니다.") + private List<@NotNull(message = "프로젝트에서의 역할은 필수입니다.") RoleName> roleNames; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptResponse.java index 0b7771ee..acc29f01 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectInvitationAcceptResponse.java @@ -1,9 +1,11 @@ package com.slatto.domain.project.dto; +import com.slatto.domain.user.enums.RoleName; import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; @Getter @Builder @@ -13,5 +15,7 @@ public class ProjectInvitationAcceptResponse { private Long memberId; + private List roleNames; + private LocalDateTime joinedAt; } diff --git a/src/main/java/com/slatto/domain/project/service/ProjectInvitationService.java b/src/main/java/com/slatto/domain/project/service/ProjectInvitationService.java index 54cdc268..068a5b0a 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectInvitationService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectInvitationService.java @@ -1,5 +1,6 @@ package com.slatto.domain.project.service; +import com.slatto.domain.project.dto.ProjectInvitationAcceptRequest; import com.slatto.domain.project.dto.ProjectInvitationAcceptResponse; import com.slatto.domain.project.dto.ProjectInvitationCreateRequest; import com.slatto.domain.project.dto.ProjectInvitationCreateResponse; @@ -7,11 +8,14 @@ import com.slatto.domain.project.entity.Project; import com.slatto.domain.project.entity.ProjectInvitation; import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.entity.ProjectUserRole; import com.slatto.domain.project.enums.ExpirationPeriod; import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.project.repository.ProjectInvitationRepository; 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.domain.user.repository.UserRepository; import com.slatto.global.config.properties.ProjectInvitationProperties; import com.slatto.global.exception.BaseException; @@ -27,6 +31,7 @@ import java.time.LocalDateTime; import java.util.Base64; import java.util.HexFormat; +import java.util.List; @Service @RequiredArgsConstructor @@ -39,6 +44,7 @@ public class ProjectInvitationService { private final ProjectInvitationRepository projectInvitationRepository; private final ProjectMemberRepository projectMemberRepository; + private final ProjectUserRoleRepository projectUserRoleRepository; private final UserRepository userRepository; private final ProjectAccessValidator projectAccessValidator; private final ProjectInvitationProperties projectInvitationProperties; @@ -84,7 +90,11 @@ public ProjectInvitationDetailResponse getInvitation(String token) { } @Transactional - public ProjectInvitationAcceptResponse acceptInvitation(String token, Long currentUserId) { + public ProjectInvitationAcceptResponse acceptInvitation( + String token, + Long currentUserId, + ProjectInvitationAcceptRequest request + ) { ProjectInvitation projectInvitation = getInvitationByTokenForUpdate(token); validateAcceptableInvitation(projectInvitation); @@ -98,15 +108,29 @@ public ProjectInvitationAcceptResponse acceptInvitation(String token, Long curre ProjectMember projectMember = ProjectMember.createMember(project, accepter); projectMemberRepository.save(projectMember); + List roleNames = request.getRoleNames() + .stream() + .distinct() + .toList(); + saveProjectRoles(projectMember, roleNames); projectInvitation.accept(accepter); return ProjectInvitationAcceptResponse.builder() .projectId(project.getId()) .memberId(projectMember.getId()) + .roleNames(roleNames) .joinedAt(projectMember.getJoinedAt()) .build(); } + private void saveProjectRoles(ProjectMember projectMember, List roleNames) { + List projectUserRoles = roleNames.stream() + .map(roleName -> ProjectUserRole.create(projectMember, roleName)) + .toList(); + + projectUserRoleRepository.saveAll(projectUserRoles); + } + private void validateAcceptableInvitation(ProjectInvitation projectInvitation) { if (projectInvitation.isAccepted()) { throw new BaseException(ProjectErrorCode.PROJECT_INVITATION_ALREADY_ACCEPTED); diff --git a/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java b/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java index 05c01b88..93bfd85a 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectMemberService.java @@ -78,7 +78,7 @@ public ProjectMemberDetailResponse updateProjectMemberRoles( ProjectMemberUpdateRequest request ) { projectAccessValidator.getProjectOrThrow(projectId); - projectAccessValidator.getCurrentAdminOrThrow(projectId, currentUserId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); ProjectMember projectMember = projectMemberRepository.findActiveMemberByProjectIdAndMemberId( projectId, @@ -86,6 +86,8 @@ public ProjectMemberDetailResponse updateProjectMemberRoles( ) .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_MEMBER_NOT_FOUND)); + validateRoleUpdatePermission(currentMember, projectMember, currentUserId); + List roleNames = request.getRoleNames() .stream() .distinct() @@ -126,6 +128,18 @@ public void leaveProject(Long projectId, Long currentUserId) { currentMember.leave(); } + private void validateRoleUpdatePermission( + ProjectMember currentMember, + ProjectMember projectMember, + Long currentUserId + ) { + if (currentMember.isAdmin() || projectMember.isMemberOf(currentUserId)) { + return; + } + + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); + } + private Map> getRoleNamesByMemberId(List projectMembers) { List projectMemberIds = projectMembers.stream() .map(ProjectMember::getId) diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index 02c1772d..631fb460 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -33,7 +33,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/auth/callback/**", "/api/v1/auth/refresh" ).permitAll() - .requestMatchers(HttpMethod.GET, "/api/v1/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers( + HttpMethod.GET, + "/api/v1/health", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/v1/project-invitations/*" + ).permitAll() .anyRequest().authenticated() ) .exceptionHandling(handling -> handling.authenticationEntryPoint(jwtAuthenticationEntryPoint))