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 5bb3bdd7..58f3eef6 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -1,11 +1,97 @@ package com.slatto.domain.project.controller; +import com.slatto.domain.project.dto.ProjectCreateRequest; +import com.slatto.domain.project.dto.ProjectDetailResponse; +import com.slatto.domain.project.dto.ProjectListResponse; +import com.slatto.domain.project.dto.ProjectResponse; +import com.slatto.domain.project.dto.ProjectUpdateRequest; +import com.slatto.domain.project.enums.ProjectStatus; +import com.slatto.domain.project.service.ProjectService; +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 jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Project", description = "프로젝트 API") @RestController @RequiredArgsConstructor @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, + @RequestParam(required = false) ProjectStatus status, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ProjectListResponse response = projectService.getProjects(currentUserId, status, cursor, size); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 생성") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProject( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @Valid @RequestBody ProjectCreateRequest request + ) { + ProjectResponse response = projectService.createProject(currentUserId, request); + + return ApiResponse.success(CommonSuccessCode.CREATED, response); + } + + @Operation(summary = "프로젝트 상세 조회") + @GetMapping("/{projectId}") + public ApiResponse getProject( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId + ) { + ProjectDetailResponse response = projectService.getProject(projectId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 수정") + @PatchMapping("/{projectId}") + public ApiResponse updateProject( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId, + @Valid @RequestBody ProjectUpdateRequest request + ) { + ProjectResponse response = projectService.updateProject(projectId, currentUserId, request); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 삭제") + @DeleteMapping("/{projectId}") + public ApiResponse deleteProject( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId + ) { + projectService.deleteProject(projectId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, null); + } } diff --git a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java new file mode 100644 index 00000000..41dfe70f --- /dev/null +++ b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java @@ -0,0 +1,136 @@ +package com.slatto.domain.project.converter; + +import com.slatto.domain.project.dto.ProjectCreateRequest; +import com.slatto.domain.project.dto.ProjectDetailResponse; +import com.slatto.domain.project.dto.ProjectListResponse; +import com.slatto.domain.project.dto.ProjectResponse; +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.RoleName; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Component +public class ProjectConverter { + + private static final int MIN_PROGRESS_PERCENT = 0; + private static final int MAX_PROGRESS_PERCENT = 100; + + public Project toProject(Users ownerUser, ProjectCreateRequest request) { + return Project.create( + ownerUser, + request.getTitle(), + request.getType(), + request.getCustomTypeName(), + request.getLengthType(), + request.getDescription(), + request.getEndDate(), + request.getClientName(), + request.getKind() + ); + } + + public ProjectResponse toResponse(Project project) { + return ProjectResponse.builder() + .id(project.getId()) + .title(project.getTitle()) + .status(project.getStatus()) + .createdAt(project.getCreatedAt()) + .updatedAt(project.getUpdatedAt()) + .build(); + } + + public ProjectListResponse toListResponse( + List items, + Long nextCursor, + Boolean hasNext + ) { + return ProjectListResponse.builder() + .items(items) + .nextCursor(nextCursor) + .hasNext(hasNext) + .build(); + } + + public ProjectListResponse.ProjectSummary toSummary( + Project project, + Long memberCount, + List memberPreviewImageUrls, + LocalDateTime lastActivityAt + ) { + return ProjectListResponse.ProjectSummary.builder() + .id(project.getId()) + .title(project.getTitle()) + .type(project.getType()) + .customTypeName(project.getCustomTypeName()) + .lengthType(project.getLengthType()) + .status(project.getStatus()) + .kind(project.getKind()) + .startDate(project.getStartDate()) + .endDate(project.getEndDate()) + .deadlineProgressPercent(calculateDeadlineProgressPercent(project.getStartDate(), project.getEndDate())) + .lastActivityAt(lastActivityAt) + .memberPreviewImageUrls(memberPreviewImageUrls) + .memberCount(memberCount) + .createdAt(project.getCreatedAt()) + .updatedAt(project.getUpdatedAt()) + .build(); + } + + public ProjectDetailResponse toDetailResponse( + Project project, + ProjectMember currentMember, + List myRoles, + Long memberCount + ) { + boolean admin = currentMember.isAdmin(); + + return ProjectDetailResponse.builder() + .id(project.getId()) + .title(project.getTitle()) + .type(project.getType()) + .customTypeName(project.getCustomTypeName()) + .lengthType(project.getLengthType()) + .description(project.getDescription()) + .startDate(project.getStartDate()) + .endDate(project.getEndDate()) + .clientName(project.getClientName()) + .status(project.getStatus()) + .kind(project.getKind()) + .owner(toOwnerSummary(project.getOwnerUser())) + .myPermission(currentMember.getPermission()) + .myRoles(myRoles) + .memberCount(memberCount) + .canEdit(admin) + .canDelete(admin) + .createdAt(project.getCreatedAt()) + .updatedAt(project.getUpdatedAt()) + .build(); + } + + private ProjectDetailResponse.OwnerSummary toOwnerSummary(Users ownerUser) { + return ProjectDetailResponse.OwnerSummary.builder() + .id(ownerUser.getId()) + .nickname(ownerUser.getNickname()) + .profileImageUrl(ownerUser.getProfileImageUrl()) + .build(); + } + + private int calculateDeadlineProgressPercent(LocalDate startDate, LocalDate endDate) { + long totalDays = ChronoUnit.DAYS.between(startDate, endDate); + if (totalDays <= 0) { + return MAX_PROGRESS_PERCENT; + } + + long elapsedDays = ChronoUnit.DAYS.between(startDate, LocalDate.now()); + long boundedElapsedDays = Math.max(0, Math.min(elapsedDays, totalDays)); + long progressPercent = boundedElapsedDays * MAX_PROGRESS_PERCENT / totalDays; + + return Math.max(MIN_PROGRESS_PERCENT, Math.min((int) progressPercent, MAX_PROGRESS_PERCENT)); + } +} 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 05a0c265..a88c1e0a 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectCreateRequest.java @@ -1,4 +1,48 @@ package com.slatto.domain.project.dto; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.RoleName; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProjectCreateRequest { + + @NotBlank(message = "프로젝트명은 필수입니다.") + @Size(max = 255, message = "프로젝트명은 최대 255자까지 입력할 수 있습니다.") + private String title; + + @NotNull(message = "프로젝트 유형은 필수입니다.") + private CategoryName type; + + @Size(max = 100, message = "직접 입력한 프로젝트 유형은 최대 100자까지 입력할 수 있습니다.") + private String customTypeName; + + @NotNull(message = "영상 길이는 필수입니다.") + private LengthType lengthType; + + @NotBlank(message = "프로젝트 설명은 필수입니다.") + private String description; + + @NotNull(message = "프로젝트 마감일은 필수입니다.") + private LocalDate endDate; + + @Size(max = 255, message = "클라이언트명은 최대 255자까지 입력할 수 있습니다.") + private String clientName; + + private Kind kind; + + @NotEmpty(message = "프로젝트에서의 역할은 1개 이상 선택해야 합니다.") + private List roleNames; } diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java new file mode 100644 index 00000000..8d5c0802 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java @@ -0,0 +1,68 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.project.enums.Permission; +import com.slatto.domain.project.enums.ProjectStatus; +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.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class ProjectDetailResponse { + + private Long id; + + private String title; + + private CategoryName type; + + private String customTypeName; + + private LengthType lengthType; + + private String description; + + private LocalDate startDate; + + private LocalDate endDate; + + private String clientName; + + private ProjectStatus status; + + private Kind kind; + + private OwnerSummary owner; + + private Permission myPermission; + + private List myRoles; + + private Long memberCount; + + private Boolean canEdit; + + private Boolean canDelete; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @Getter + @Builder + public static class OwnerSummary { + + private Long id; + + private String nickname; + + private String profileImageUrl; + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java new file mode 100644 index 00000000..dce1728e --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java @@ -0,0 +1,58 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.project.enums.ProjectStatus; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class ProjectListResponse { + + private List items; + + private Long nextCursor; + + private Boolean hasNext; + + @Getter + @Builder + public static class ProjectSummary { + + private Long id; + + private String title; + + private CategoryName type; + + private String customTypeName; + + private LengthType lengthType; + + private ProjectStatus status; + + private Kind kind; + + private LocalDate startDate; + + private LocalDate endDate; + + private Integer deadlineProgressPercent; + + private LocalDateTime lastActivityAt; + + private List memberPreviewImageUrls; + + private Long memberCount; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectResponse.java index 3b37284a..ad7585ff 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectResponse.java @@ -1,4 +1,22 @@ package com.slatto.domain.project.dto; +import com.slatto.domain.project.enums.ProjectStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder public class ProjectResponse { + + private Long id; + + private String title; + + private ProjectStatus status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; } diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java new file mode 100644 index 00000000..bb1bd6d7 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectUpdateRequest.java @@ -0,0 +1,45 @@ +package com.slatto.domain.project.dto; + +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.project.enums.ProjectStatus; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectUpdateRequest { + + @NotBlank(message = "프로젝트명은 필수입니다.") + @Size(max = 255, message = "프로젝트명은 최대 255자까지 입력할 수 있습니다.") + private String title; + + @NotNull(message = "프로젝트 유형은 필수입니다.") + private CategoryName type; + + @Size(max = 100, message = "직접 입력한 프로젝트 유형은 최대 100자까지 입력할 수 있습니다.") + private String customTypeName; + + @NotNull(message = "영상 길이는 필수입니다.") + private LengthType lengthType; + + @NotBlank(message = "프로젝트 설명은 필수입니다.") + private String description; + + @NotNull(message = "프로젝트 마감일은 필수입니다.") + private LocalDate endDate; + + @Size(max = 255, message = "클라이언트명은 최대 255자까지 입력할 수 있습니다.") + private String clientName; + + private ProjectStatus status; + + private Kind kind; +} 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 c5d2a804..611dc917 100644 --- a/src/main/java/com/slatto/domain/project/entity/Project.java +++ b/src/main/java/com/slatto/domain/project/entity/Project.java @@ -3,9 +3,11 @@ import com.slatto.domain.common.entity.BaseEntity; import com.slatto.domain.project.enums.LengthType; import com.slatto.domain.project.enums.ProjectStatus; +import com.slatto.domain.project.exception.ProjectErrorCode; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.CategoryName; import com.slatto.domain.user.enums.Kind; +import com.slatto.global.exception.BaseException; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; @@ -20,6 +22,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Project extends BaseEntity { + private static final ProjectStatus DEFAULT_STATUS = ProjectStatus.PREPARING; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -65,4 +69,91 @@ public class Project extends BaseEntity { @Column(name = "deleted_at", nullable = true) private LocalDateTime deletedAt; + + private Project( + Users ownerUser, + String title, + CategoryName type, + String customTypeName, + LengthType lengthType, + String description, + LocalDate endDate, + String clientName, + Kind kind + ) { + LocalDate startDate = LocalDate.now(); + validateProjectPeriod(startDate, endDate); + + this.ownerUser = ownerUser; + this.title = title; + this.type = type; + this.customTypeName = customTypeName; + this.lengthType = lengthType; + this.description = description; + this.startDate = startDate; + this.endDate = endDate; + this.clientName = clientName; + this.status = DEFAULT_STATUS; + this.kind = kind; + } + + public static Project create( + Users ownerUser, + String title, + CategoryName type, + String customTypeName, + LengthType lengthType, + String description, + LocalDate endDate, + String clientName, + Kind kind + ) { + return new Project( + ownerUser, + title, + type, + customTypeName, + lengthType, + description, + endDate, + clientName, + kind + ); + } + + public void updateInfo( + String title, + CategoryName type, + String customTypeName, + LengthType lengthType, + String description, + LocalDate endDate, + String clientName, + Kind kind + ) { + validateProjectPeriod(this.startDate, endDate); + + this.title = title; + this.type = type; + this.customTypeName = customTypeName; + this.lengthType = lengthType; + this.description = description; + this.endDate = endDate; + this.clientName = clientName; + this.kind = kind; + } + + public void changeStatus(ProjectStatus status) { + this.status = status; + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + private void validateProjectPeriod(LocalDate startDate, LocalDate endDate) { + if (endDate == null || endDate.isBefore(startDate)) { + throw new BaseException(ProjectErrorCode.INVALID_PROJECT_PERIOD); + } + } } diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectInvitation.java b/src/main/java/com/slatto/domain/project/entity/ProjectInvitation.java index 55de0358..0c223d51 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectInvitation.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectInvitation.java @@ -1,7 +1,6 @@ package com.slatto.domain.project.entity; import com.slatto.domain.common.entity.BaseEntity; -import com.slatto.domain.project.enums.ExpirationPeriod; import com.slatto.domain.user.entity.Users; import jakarta.persistence.*; import lombok.AccessLevel; @@ -36,10 +35,9 @@ public class ProjectInvitation extends BaseEntity { @Column(name = "token_hash", nullable = false, length = 255, unique = true) private String tokenHash; - @Enumerated(EnumType.STRING) @Column(name = "expired_at", nullable = false) - private ExpirationPeriod expiredAt; // 확인 필요! + private LocalDateTime expiresAt; @Column(name = "accepted_at", nullable = true) private LocalDateTime acceptedAt; -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectMember.java b/src/main/java/com/slatto/domain/project/entity/ProjectMember.java index e596c446..27a48002 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectMember.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectMember.java @@ -37,4 +37,31 @@ public class ProjectMember { @Column(name = "left_at", nullable = true) private LocalDateTime leftAt; -} \ No newline at end of file + + private ProjectMember(Project project, Users user, Permission permission) { + this.project = project; + this.user = user; + this.permission = permission; + this.joinedAt = LocalDateTime.now(); + } + + public static ProjectMember createAdmin(Project project, Users user) { + return new ProjectMember(project, user, Permission.ADMIN); + } + + public static ProjectMember createMember(Project project, Users user) { + return new ProjectMember(project, user, Permission.MEMBER); + } + + public boolean isAdmin() { + return permission == Permission.ADMIN; + } + + public boolean isMemberOf(Long userId) { + return user.getId().equals(userId); + } + + public void leave() { + this.leftAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java b/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java index a57959a4..3d925a46 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java @@ -28,13 +28,13 @@ public class ProjectNotice extends BaseEntity { @JoinColumn(name = "writer_id", nullable = false) private Users writer; - @Column(name = "title", nullable = true, length = 255) + @Column(name = "title", nullable = false, length = 255) private String title; - @Column(name = "content", nullable = true, columnDefinition = "TEXT") + @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; @Column(name = "deleted_at", nullable = true) private LocalDateTime deletedAt; -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectUserRole.java b/src/main/java/com/slatto/domain/project/entity/ProjectUserRole.java index 9253276d..f176e330 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectUserRole.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectUserRole.java @@ -24,4 +24,13 @@ public class ProjectUserRole { @Enumerated(EnumType.STRING) @Column(name = "role_name", nullable = false) private RoleName roleName; -} \ No newline at end of file + + private ProjectUserRole(ProjectMember projectMember, RoleName roleName) { + this.projectMember = projectMember; + this.roleName = roleName; + } + + public static ProjectUserRole create(ProjectMember projectMember, RoleName roleName) { + return new ProjectUserRole(projectMember, roleName); + } +} diff --git a/src/main/java/com/slatto/domain/project/enums/ProjectStatus.java b/src/main/java/com/slatto/domain/project/enums/ProjectStatus.java index 998cba3d..62c1fe7d 100644 --- a/src/main/java/com/slatto/domain/project/enums/ProjectStatus.java +++ b/src/main/java/com/slatto/domain/project/enums/ProjectStatus.java @@ -1,8 +1,8 @@ package com.slatto.domain.project.enums; public enum ProjectStatus { - PLANNING, // 기획중 - SHOOTING, // 촬영중 + PREPARING, // 준비중 EDITING, // 편집중 + REVIEWING, // 검토중 COMPLETED // 완료 -} \ No newline at end of file +} diff --git a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java new file mode 100644 index 00000000..627dff0b --- /dev/null +++ b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java @@ -0,0 +1,27 @@ +package com.slatto.domain.project.exception; + +import com.slatto.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProjectErrorCode implements BaseCode { + + INVALID_PROJECT_PERIOD(HttpStatus.BAD_REQUEST, "PROJECT400", "프로젝트 마감일은 시작일보다 이전일 수 없습니다."), + PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "PROJECT404", "프로젝트를 찾을 수 없습니다."), + PROJECT_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "PROJECT_MEMBER404", "프로젝트 멤버를 찾을 수 없습니다."), + PROJECT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PROJECT403", "프로젝트 접근 권한이 없습니다."), + PROJECT_ADMIN_REQUIRED(HttpStatus.FORBIDDEN, "PROJECT_ADMIN403", "프로젝트 관리자 권한이 필요합니다."), + PROJECT_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "PROJECT409", "무료 계정은 최대 5개의 프로젝트를 생성할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public boolean isSuccess() { + return false; + } +} diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java new file mode 100644 index 00000000..3db384e6 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java @@ -0,0 +1,51 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.enums.ProjectStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ProjectMemberRepository extends JpaRepository { + + Optional findByProjectIdAndUserIdAndLeftAtIsNull(Long projectId, Long userId); + + boolean existsByProjectIdAndUserIdAndLeftAtIsNull(Long projectId, Long userId); + + long countByProjectIdAndLeftAtIsNull(Long projectId); + + @Query(""" + select pm + from ProjectMember pm + join fetch pm.user u + where pm.project.id = :projectId + and pm.leftAt is null + order by pm.id asc + """) + List findActiveMembersByProjectId( + @Param("projectId") Long projectId, + Pageable pageable + ); + + @Query(""" + select pm + from ProjectMember pm + join fetch pm.project p + where pm.user.id = :userId + and pm.leftAt is null + and p.deletedAt is null + and (:status is null or p.status = :status) + and (:cursor is null or p.id < :cursor) + order by p.id desc + """) + List findJoinedProjectsByCursor( + @Param("userId") Long userId, + @Param("status") ProjectStatus status, + @Param("cursor") Long cursor, + Pageable pageable + ); +} diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectRepository.java index 25d6e566..e09699e8 100644 --- a/src/main/java/com/slatto/domain/project/repository/ProjectRepository.java +++ b/src/main/java/com/slatto/domain/project/repository/ProjectRepository.java @@ -1,7 +1,13 @@ package com.slatto.domain.project.repository; -import org.springframework.stereotype.Repository; +import com.slatto.domain.project.entity.Project; +import org.springframework.data.jpa.repository.JpaRepository; -@Repository -public interface ProjectRepository { +import java.util.Optional; + +public interface ProjectRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + long countByOwnerUserIdAndDeletedAtIsNull(Long ownerUserId); } diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java new file mode 100644 index 00000000..1f488618 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectUserRoleRepository.java @@ -0,0 +1,14 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectUserRole; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface ProjectUserRoleRepository extends JpaRepository { + + List findAllByProjectMemberId(Long projectMemberId); + + List findAllByProjectMemberIdIn(Collection projectMemberIds); +} 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 014e23e4..3edaf496 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -1,9 +1,228 @@ package com.slatto.domain.project.service; +import com.slatto.domain.project.converter.ProjectConverter; +import com.slatto.domain.project.dto.ProjectCreateRequest; +import com.slatto.domain.project.dto.ProjectDetailResponse; +import com.slatto.domain.project.dto.ProjectListResponse; +import com.slatto.domain.project.dto.ProjectResponse; +import com.slatto.domain.project.dto.ProjectUpdateRequest; +import com.slatto.domain.project.entity.Project; +import com.slatto.domain.project.entity.ProjectMember; +import com.slatto.domain.project.entity.ProjectUserRole; +import com.slatto.domain.project.enums.ProjectStatus; +import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectMemberRepository; +import com.slatto.domain.project.repository.ProjectRepository; +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.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 java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class ProjectService { + + private static final int FREE_PROJECT_LIMIT = 5; + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + private static final int MEMBER_PREVIEW_LIMIT = 4; + + private final ProjectRepository projectRepository; + private final ProjectMemberRepository projectMemberRepository; + private final ProjectUserRoleRepository projectUserRoleRepository; + private final UserRepository userRepository; + private final ProjectConverter projectConverter; + + @Transactional + public ProjectResponse createProject(Long ownerUserId, ProjectCreateRequest request) { + Users ownerUser = getActiveUser(ownerUserId); + validateProjectCreationQuota(ownerUserId); + + Project project = projectConverter.toProject(ownerUser, request); + Project savedProject = projectRepository.save(project); + + ProjectMember ownerMember = projectMemberRepository.save( + ProjectMember.createAdmin(savedProject, ownerUser) + ); + saveProjectRoles(ownerMember, request.getRoleNames()); + + return projectConverter.toResponse(savedProject); + } + + public ProjectListResponse getProjects( + Long currentUserId, + ProjectStatus status, + Long cursor, + int size + ) { + validateActiveUserExists(currentUserId); + + int pageSize = normalizePageSize(size); + List projectMembers = projectMemberRepository.findJoinedProjectsByCursor( + currentUserId, + status, + cursor, + PageRequest.of(0, pageSize + 1) + ); + + boolean hasNext = projectMembers.size() > pageSize; + List currentPageMembers = projectMembers.stream() + .limit(pageSize) + .toList(); + + List items = currentPageMembers.stream() + .map(ProjectMember::getProject) + .map(project -> projectConverter.toSummary( + project, + countActiveMembers(project), + getMemberPreviewImageUrls(project), + resolveLastActivityAt(project) + )) + .toList(); + + Long nextCursor = hasNext && !items.isEmpty() + ? items.get(items.size() - 1).getId() + : null; + + return projectConverter.toListResponse(items, nextCursor, hasNext); + } + + public ProjectDetailResponse getProject(Long projectId, Long currentUserId) { + Project project = getProjectOrThrow(projectId); + ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); + + List myRoles = projectUserRoleRepository.findAllByProjectMemberId(currentMember.getId()) + .stream() + .map(ProjectUserRole::getRoleName) + .toList(); + + return projectConverter.toDetailResponse( + project, + currentMember, + myRoles, + countActiveMembers(project) + ); + } + + @Transactional + public ProjectResponse updateProject( + Long projectId, + Long currentUserId, + ProjectUpdateRequest request + ) { + Project project = getProjectOrThrow(projectId); + ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); + validateAdmin(currentMember); + + project.updateInfo( + request.getTitle(), + request.getType(), + request.getCustomTypeName(), + request.getLengthType(), + request.getDescription(), + request.getEndDate(), + request.getClientName(), + request.getKind() + ); + + if (request.getStatus() != null) { + project.changeStatus(request.getStatus()); + } + + return projectConverter.toResponse(project); + } + + @Transactional + public void deleteProject(Long projectId, Long currentUserId) { + Project project = getProjectOrThrow(projectId); + ProjectMember currentMember = getCurrentMemberOrThrow(projectId, currentUserId); + validateAdmin(currentMember); + + project.delete(); + } + + private Users getActiveUser(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + } + + private void validateActiveUserExists(Long userId) { + if (!userRepository.existsByIdAndDeletedAtIsNull(userId)) { + throw new BaseException(CommonErrorCode.NOT_FOUND); + } + } + + private void validateProjectCreationQuota(Long ownerUserId) { + long projectCount = projectRepository.countByOwnerUserIdAndDeletedAtIsNull(ownerUserId); + if (projectCount >= FREE_PROJECT_LIMIT) { + throw new BaseException(ProjectErrorCode.PROJECT_LIMIT_EXCEEDED); + } + } + + 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; + } + + List projectUserRoles = roleNames.stream() + .map(roleName -> ProjectUserRole.create(projectMember, roleName)) + .toList(); + + projectUserRoleRepository.saveAll(projectUserRoles); + } + + private long countActiveMembers(Project project) { + return projectMemberRepository.countByProjectIdAndLeftAtIsNull(project.getId()); + } + + private List getMemberPreviewImageUrls(Project project) { + return projectMemberRepository.findActiveMembersByProjectId( + project.getId(), + PageRequest.of(0, MEMBER_PREVIEW_LIMIT) + ) + .stream() + .map(ProjectMember::getUser) + .map(Users::getProfileImageUrl) + .filter(Objects::nonNull) + .toList(); + } + + private LocalDateTime resolveLastActivityAt(Project project) { + return project.getUpdatedAt() != null ? project.getUpdatedAt() : project.getCreatedAt(); + } + + private int normalizePageSize(int size) { + if (size <= 0) { + return DEFAULT_PAGE_SIZE; + } + + return Math.min(size, MAX_PAGE_SIZE); + } } diff --git a/src/main/java/com/slatto/domain/user/enums/CategoryName.java b/src/main/java/com/slatto/domain/user/enums/CategoryName.java index 38fc2e4d..7383b333 100644 --- a/src/main/java/com/slatto/domain/user/enums/CategoryName.java +++ b/src/main/java/com/slatto/domain/user/enums/CategoryName.java @@ -1,10 +1,12 @@ package com.slatto.domain.user.enums; public enum CategoryName { - MOVIE, // 영화 - DOCUMENTARY, // 다큐/시사/교양 - DRAMA, // 드라마 + YOUTUBE_CONTENT, // 유튜브 콘텐츠 + AD_BRAND, // 광고/브랜드 영상 MUSIC_VIDEO, // 뮤직비디오 - ENTERTAINMENT, // 예능/오락 - ADVERTISEMENT // 광고/홍보영상 + WEDDING_EVENT, // 웨딩/이벤트 영상 + DOCUMENTARY, // 다큐멘터리 + FILM_DRAMA, // 영화/드라마 + CORPORATE_PROMO, // 기업 홍보 영상 + ETC // 기타 } diff --git a/src/main/java/com/slatto/domain/user/enums/RoleName.java b/src/main/java/com/slatto/domain/user/enums/RoleName.java index 8e61b712..c6933052 100644 --- a/src/main/java/com/slatto/domain/user/enums/RoleName.java +++ b/src/main/java/com/slatto/domain/user/enums/RoleName.java @@ -1,10 +1,14 @@ package com.slatto.domain.user.enums; public enum RoleName { - DIRECTOR, // 감독 - EDITOR, // 편집 + DIRECTOR, // 연출 + PD, // PD CINEMATOGRAPHER, // 촬영 - SOUND, // 음향 - PD, // 피디 - ART // 미술 + EDITOR, // 편집 + ART, // 미술 + SOUND, // 사운드 + WRITER, // 작가 + LIGHTING, // 조명 + ACTOR, // 배우 + ETC // 기타 } diff --git a/src/main/java/com/slatto/domain/user/repository/UserRepository.java b/src/main/java/com/slatto/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..062dee1d --- /dev/null +++ b/src/main/java/com/slatto/domain/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.slatto.domain.user.repository; + +import com.slatto.domain.user.entity.Users; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + boolean existsByIdAndDeletedAtIsNull(Long id); +}