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
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,23 @@ public ApiResponse<ProjectDetailResponse> getProject(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로젝트 수정")
@Operation(
summary = "프로젝트 수정",
description = """
`status` 를 `COMPLETED` 로 바꾸면 참여 중인 멤버 전원의 포트폴리오에 이 프로젝트가 생성된다.
프로젝트명·유형·개인외주 구분·설명·기간이 그대로 옮겨가고, 각자 맡은 역할이 함께 채워진다.
생성된 뒤에는 본인이 프로필에서 수정·삭제할 수 있다.

나간 멤버와 탈퇴한 유저는 대상에서 빠진다.

`COMPLETED` 는 최종 상태다. 완료한 뒤에는 다른 단계로 되돌릴 수 없고
시도하면 `PROJECT_COMPLETED409` 가 나간다. 이력이 두 번 생기는 것을 막기 위해서다.

`title` 또는 `kind` 가 비어 있으면 포트폴리오를 만들 수 없어 완료로 바꿀 수 없다.
이때는 `PROJECT_COMPLETION400` 이 나간다.
`title` 은 생성·수정 요청 모두 필수라 실제로는 `kind` 만 이 조건에 걸린다.
"""
)
@PatchMapping("/{projectId}")
public ApiResponse<ProjectResponse> updateProject(
@AuthenticationPrincipal Long currentUserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ public enum ProjectErrorCode implements BaseCode {
PROJECT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PROJECT403", "프로젝트 접근 권한이 없습니다."),
PROJECT_ADMIN_REQUIRED(HttpStatus.FORBIDDEN, "PROJECT_ADMIN403", "프로젝트 관리자 권한이 필요합니다."),
PROJECT_MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "PROJECT_MEMBER409", "이미 프로젝트에 참여 중인 멤버입니다."),
PROJECT_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "PROJECT409", "무료 계정은 최대 5개의 프로젝트를 생성할 수 있습니다.");
PROJECT_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "PROJECT409", "무료 계정은 최대 5개의 프로젝트를 생성할 수 있습니다."),
PROJECT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "PROJECT_COMPLETED409", "완료된 프로젝트는 진행 단계를 변경할 수 없습니다."),
PROJECT_COMPLETION_INFO_REQUIRED(HttpStatus.BAD_REQUEST, "PROJECT_COMPLETION400", "프로젝트 제목과 개인/외주 구분을 입력해야 완료할 수 있습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.slatto.domain.project.entity.Project;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

Expand All @@ -10,4 +13,19 @@ public interface ProjectRepository extends JpaRepository<Project, Long> {
Optional<Project> findByIdAndDeletedAtIsNull(Long id);

long countByOwnerUserIdAndDeletedAtIsNull(Long ownerUserId);

// 완료 전환을 조건부 UPDATE 로 선점한다. 동시에 완료를 눌러도 한 요청만 1을 받는다.
// 조회 후 분기로 처리하면 두 요청이 모두 통과해 참여자 포트폴리오가 두 벌 생긴다.
//
// clearAutomatically 는 쓰지 않는다. 호출 시점에 Project 는 이미 로딩되어 변경된 상태라
// 영속성 컨텍스트를 비우면 그 변경이 사라진다. 호출자가 엔티티 상태를 직접 맞춘다.
@Modifying(flushAutomatically = true)
@Query("""
UPDATE Project p
SET p.status = com.slatto.domain.project.enums.ProjectStatus.COMPLETED
WHERE p.id = :id
AND p.status <> com.slatto.domain.project.enums.ProjectStatus.COMPLETED
AND p.deletedAt IS NULL
""")
int markCompleted(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@
import com.slatto.domain.notification.repository.ActivityLogRepository;
import com.slatto.domain.notification.repository.ProjectLatestActivityProjection;
import com.slatto.domain.notification.service.ActivityLogService;
import com.slatto.domain.user.dto.ProjectPortfolioCreateCommand;
import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.enums.RoleName;
import com.slatto.domain.user.repository.UserRepository;
import com.slatto.domain.user.service.PortfolioService;
import com.slatto.domain.video.repository.VideoRepository;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;
import java.util.List;
Expand All @@ -55,6 +58,7 @@ public class ProjectService {
private final VideoRepository videoRepository;
private final ProjectConverter projectConverter;
private final ProjectAccessValidator projectAccessValidator;
private final PortfolioService portfolioService;
private final ActivityLogService activityLogService;
private final ActivityLogRepository activityLogRepository;

Expand Down Expand Up @@ -175,6 +179,14 @@ public ProjectResponse updateProject(
projectAccessValidator.getCurrentAdminOrThrow(projectId, currentUserId);
ProjectStatus previousStatus = project.getStatus();

// 완료는 최종 상태다. 참여자 포트폴리오가 이미 만들어졌기 때문에 되돌리면
// 다시 완료할 때 같은 이력이 두 번 생긴다.
if (previousStatus == ProjectStatus.COMPLETED
&& request.getStatus() != null
&& request.getStatus() != ProjectStatus.COMPLETED) {
throw new BaseException(ProjectErrorCode.PROJECT_ALREADY_COMPLETED);
}

project.updateInfo(
request.getTitle(),
request.getType(),
Expand All @@ -185,7 +197,10 @@ public ProjectResponse updateProject(
request.getKind()
);

if (request.getStatus() != null) {
// 같은 요청에서 바뀐 제목·종류로 검증해야 하므로 updateInfo 다음에 처리한다.
if (request.getStatus() == ProjectStatus.COMPLETED && previousStatus != ProjectStatus.COMPLETED) {
completeProject(project);
} else if (request.getStatus() != null) {
project.changeStatus(request.getStatus());
}

Expand All @@ -203,6 +218,68 @@ public ProjectResponse updateProject(
return projectConverter.toResponse(project);
}

// 완료 전환과 포트폴리오 생성을 한 트랜잭션에서 처리한다.
// 포트폴리오 생성이 실패하면 완료 전환도 함께 롤백되어야 한다.
private void completeProject(Project project) {
if (!StringUtils.hasText(project.getTitle()) || project.getKind() == null) {
throw new BaseException(ProjectErrorCode.PROJECT_COMPLETION_INFO_REQUIRED);
}

if (projectRepository.markCompleted(project.getId()) == 0) {
throw new BaseException(ProjectErrorCode.PROJECT_ALREADY_COMPLETED);
}

// 벌크 UPDATE 는 영속성 컨텍스트를 거치지 않는다. 메모리 상태를 맞추지 않으면
// 커밋 시점의 더티 체킹 UPDATE 가 예전 status 로 덮어쓴다.
project.changeStatus(ProjectStatus.COMPLETED);

portfolioService.createProjectPortfolios(toPortfolioCommand(project));
}

private ProjectPortfolioCreateCommand toPortfolioCommand(Project project) {
// 나간 멤버와 탈퇴한 유저는 이력을 받지 않는다.
List<ProjectMember> members = projectMemberRepository
.findAllActiveMembersByProjectId(project.getId())
.stream()
.filter(member -> member.getUser().getDeletedAt() == null)
.toList();

Map<Long, List<RoleName>> roleNamesByMemberId = findRoleNamesByMemberIds(
members.stream().map(ProjectMember::getId).toList()
);

List<ProjectPortfolioCreateCommand.Participant> participants = members.stream()
.map(member -> ProjectPortfolioCreateCommand.Participant.builder()
.user(member.getUser())
.roles(roleNamesByMemberId.getOrDefault(member.getId(), List.of()))
.build())
.toList();

return ProjectPortfolioCreateCommand.builder()
.title(project.getTitle())
.type(project.getType())
.kind(project.getKind())
.clientName(project.getClientName())
.description(project.getDescription())
.startDate(project.getStartDate())
.endDate(project.getEndDate())
.participants(participants)
.build();
}

private Map<Long, List<RoleName>> findRoleNamesByMemberIds(List<Long> memberIds) {
if (memberIds.isEmpty()) {
return Map.of();
}

return projectUserRoleRepository.findAllByProjectMemberIdIn(memberIds)
.stream()
.collect(Collectors.groupingBy(
role -> role.getProjectMember().getId(),
Collectors.mapping(ProjectUserRole::getRoleName, Collectors.toList())
));
}

@Transactional
public void deleteProject(Long projectId, Long currentUserId) {
Project project = projectAccessValidator.getProjectOrThrow(projectId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.util.List;

@Getter
Expand Down Expand Up @@ -44,4 +45,9 @@ public class PortfolioCreateRequest {
@NotBlank(message = "영상 링크는 필수입니다.")
@Size(max = 500, message = "영상 링크는 500자 이하로 입력해야 합니다.")
private String youtubeUrl;

// 참여 기간은 선택 입력이다. 기간을 모르는 예전 작업도 등록할 수 있어야 한다.
private LocalDate startDate;

private LocalDate endDate;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

Expand Down Expand Up @@ -35,6 +36,10 @@ public class PortfolioDetailResponse {

private String thumbnailUrl;

private LocalDate startDate;

private LocalDate endDate;

private LocalDateTime createdAt;

private LocalDateTime updatedAt;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

Expand All @@ -24,5 +25,9 @@ public class PortfolioSummaryResponse {

private String thumbnailUrl;

private LocalDate startDate;

private LocalDate endDate;

private LocalDateTime createdAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;
import java.util.List;

@Getter
Expand Down Expand Up @@ -37,4 +38,9 @@ public class PortfolioUpdateRequest {

@Size(max = 500, message = "영상 링크는 500자 이하로 입력해야 합니다.")
private String youtubeUrl;

// 다른 필드와 같이 null 은 "바꾸지 않음"이다. 한쪽만 보내면 나머지는 기존 값을 유지한다.
private LocalDate startDate;

private LocalDate endDate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.slatto.domain.user.dto;

import com.slatto.domain.user.entity.Users;
import com.slatto.domain.user.enums.CategoryName;
import com.slatto.domain.user.enums.Kind;
import com.slatto.domain.user.enums.RoleName;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

// 프로젝트가 완료될 때 참여자 포트폴리오를 만들기 위한 입력이다.
// Project 를 그대로 받지 않는 이유는 PortfolioService 가 project 도메인을 참조하면
// ProjectService 와 순환 참조가 생기기 때문이다. 필요한 값만 옮겨 담아 전달한다.
@Getter
@Builder
public class ProjectPortfolioCreateCommand {

private String title;

private CategoryName type;

private Kind kind;

private String clientName;

private String description;

private LocalDate startDate;

private LocalDate endDate;

private List<Participant> participants;

@Getter
@Builder
public static class Participant {

private Users user;

// 역할이 지정되지 않은 멤버도 포트폴리오를 받는다. 이때는 빈 목록이다.
private List<RoleName> roles;
}
}
Loading
Loading