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 d7e4d9ce..f7ef871e 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -3,6 +3,7 @@ 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.ProjectPinResponse; import com.slatto.domain.project.dto.ProjectResponse; import com.slatto.domain.project.dto.ProjectUpdateRequest; import com.slatto.domain.project.enums.ProjectStatus; @@ -92,4 +93,26 @@ public ApiResponse deleteProject( return ApiResponse.success(CommonSuccessCode.OK, null); } + + @Operation(summary = "프로젝트 고정") + @PostMapping("/{projectId}/pin") + public ApiResponse pinProject( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId + ) { + ProjectPinResponse response = projectService.pinProject(projectId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 고정 해제") + @DeleteMapping("/{projectId}/pin") + public ApiResponse unpinProject( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId + ) { + ProjectPinResponse response = projectService.unpinProject(projectId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } } diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java index fc2f06cd..3a854f49 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectFileController.java @@ -2,6 +2,7 @@ import com.slatto.domain.project.dto.ProjectFileDownloadResponse; import com.slatto.domain.project.dto.ProjectFileListResponse; +import com.slatto.domain.project.dto.ProjectFilePinResponse; import com.slatto.domain.project.dto.ProjectFileResponse; import com.slatto.domain.project.dto.ProjectFileUpdateRequest; import com.slatto.domain.project.dto.ProjectFileUploadRequest; @@ -110,6 +111,38 @@ public ApiResponse deleteProjectFile( return ApiResponse.success(CommonSuccessCode.OK, null); } + @Operation(summary = "프로젝트 파일 고정") + @PostMapping("/{fileId}/pin") + public ApiResponse pinProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long fileId + ) { + ProjectFilePinResponse response = projectFileService.pinProjectFile( + projectId, + fileId, + currentUserId + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 파일 고정 해제") + @DeleteMapping("/{fileId}/pin") + public ApiResponse unpinProjectFile( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long fileId + ) { + ProjectFilePinResponse response = projectFileService.unpinProjectFile( + projectId, + fileId, + currentUserId + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + @Operation(summary = "프로젝트 파일 다운로드") @GetMapping("/{fileId}/download") public ResponseEntity downloadProjectFile( 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 0123abe7..abaad240 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -2,6 +2,7 @@ import com.slatto.domain.project.dto.ProjectNoticeCreateRequest; import com.slatto.domain.project.dto.ProjectNoticeListResponse; +import com.slatto.domain.project.dto.ProjectNoticeReadResponse; import com.slatto.domain.project.dto.ProjectNoticeResponse; import com.slatto.domain.project.dto.ProjectNoticeUpdateRequest; import com.slatto.domain.project.service.ProjectNoticeService; @@ -96,4 +97,20 @@ public ApiResponse deleteProjectNotice( return ApiResponse.success(CommonSuccessCode.OK, null); } + + @Operation(summary = "프로젝트 공지 읽음 처리") + @PatchMapping("/{noticeId}/read") + public ApiResponse readProjectNotice( + @AuthenticationPrincipal Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long noticeId + ) { + ProjectNoticeReadResponse response = projectNoticeService.readProjectNotice( + projectId, + noticeId, + 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 d349cb7f..c64185cb 100644 --- a/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java +++ b/src/main/java/com/slatto/domain/project/converter/ProjectConverter.java @@ -3,9 +3,12 @@ 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.ProjectPinResponse; 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.project.entity.ProjectPin; +import com.slatto.domain.project.enums.Permission; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.RoleName; import org.springframework.stereotype.Component; @@ -44,6 +47,14 @@ public ProjectResponse toResponse(Project project) { .build(); } + public ProjectPinResponse toPinResponse(Long projectId, LocalDateTime pinnedAt) { + return ProjectPinResponse.builder() + .id(projectId) + .isPinned(pinnedAt != null) + .pinnedAt(pinnedAt) + .build(); + } + public ProjectListResponse toListResponse( List items, Long nextCursor, @@ -60,6 +71,10 @@ public ProjectListResponse.ProjectSummary toSummary( Project project, Long memberCount, List memberPreviewImageUrls, + List roleNames, + String previewImageUrl, + LocalDateTime pinnedAt, + Permission myPermission, LocalDateTime lastActivityAt ) { return ProjectListResponse.ProjectSummary.builder() @@ -69,12 +84,17 @@ public ProjectListResponse.ProjectSummary toSummary( .lengthType(project.getLengthType()) .status(project.getStatus()) .kind(project.getKind()) + .roleNames(roleNames) + .previewImageUrl(previewImageUrl) .startDate(project.getStartDate()) .endDate(project.getEndDate()) .deadlineProgressPercent(calculateDeadlineProgressPercent(project.getStartDate(), project.getEndDate())) .lastActivityAt(lastActivityAt) .memberPreviewImageUrls(memberPreviewImageUrls) .memberCount(memberCount) + .isPinned(pinnedAt != null) + .pinnedAt(pinnedAt) + .myPermission(myPermission) .createdAt(project.getCreatedAt()) .updatedAt(project.getUpdatedAt()) .build(); @@ -84,9 +104,10 @@ public ProjectDetailResponse toDetailResponse( Project project, ProjectMember currentMember, List roleNames, + ProjectPin projectPin, Long memberCount ) { - boolean admin = currentMember.isAdmin(); + LocalDateTime pinnedAt = projectPin != null ? projectPin.getPinnedAt() : null; return ProjectDetailResponse.builder() .id(project.getId()) @@ -103,8 +124,8 @@ public ProjectDetailResponse toDetailResponse( .myPermission(currentMember.getPermission()) .roleNames(roleNames) .memberCount(memberCount) - .canEdit(admin) - .canDelete(admin) + .isPinned(pinnedAt != null) + .pinnedAt(pinnedAt) .createdAt(project.getCreatedAt()) .updatedAt(project.getUpdatedAt()) .build(); 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 d2d6bd03..28a0c740 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectDetailResponse.java @@ -45,9 +45,9 @@ public class ProjectDetailResponse { private Long memberCount; - private Boolean canEdit; + private Boolean isPinned; - private Boolean canDelete; + private LocalDateTime pinnedAt; private LocalDateTime createdAt; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFilePinResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectFilePinResponse.java new file mode 100644 index 00000000..aefe9783 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFilePinResponse.java @@ -0,0 +1,17 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ProjectFilePinResponse { + + private Long id; + + private Boolean isPinned; + + private LocalDateTime pinnedAt; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java index 6039129d..5d5a3dc9 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileUpdateRequest.java @@ -14,7 +14,5 @@ public class ProjectFileUpdateRequest { private String description; - private Boolean isPinned; - private Boolean isFinal; } diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java index c4a43241..004266c4 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectFileUploadRequest.java @@ -16,7 +16,5 @@ public class ProjectFileUploadRequest { private String description; - private Boolean isPinned; - private Boolean isFinal; } 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 bace11ec..bfd43baf 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectListResponse.java @@ -1,9 +1,11 @@ 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; @@ -37,6 +39,10 @@ public static class ProjectSummary { private Kind kind; + private List roleNames; + + private String previewImageUrl; + private LocalDate startDate; private LocalDate endDate; @@ -49,6 +55,12 @@ public static class ProjectSummary { private Long memberCount; + private Boolean isPinned; + + private LocalDateTime pinnedAt; + + private Permission myPermission; + private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeReadResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeReadResponse.java new file mode 100644 index 00000000..346e0cb0 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeReadResponse.java @@ -0,0 +1,17 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ProjectNoticeReadResponse { + + private Long id; + + private Boolean isRead; + + private LocalDateTime readAt; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java index 932d4d84..d0f2be34 100644 --- a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java @@ -17,6 +17,8 @@ public class ProjectNoticeResponse { private WriterSummary writer; + private Boolean isRead; + private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectPinResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectPinResponse.java new file mode 100644 index 00000000..bb6e0390 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectPinResponse.java @@ -0,0 +1,17 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ProjectPinResponse { + + private Long id; + + private Boolean isPinned; + + private LocalDateTime pinnedAt; +} diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectNoticeRead.java b/src/main/java/com/slatto/domain/project/entity/ProjectNoticeRead.java new file mode 100644 index 00000000..604702b4 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/entity/ProjectNoticeRead.java @@ -0,0 +1,53 @@ +package com.slatto.domain.project.entity; + +import com.slatto.domain.common.entity.BaseEntity; +import com.slatto.domain.user.entity.Users; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "project_notice_read", + uniqueConstraints = @UniqueConstraint( + name = "uk_project_notice_read_notice_user", + columnNames = {"notice_id", "user_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectNoticeRead extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notice_id", nullable = false) + private ProjectNotice notice; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @Column(name = "read_at", nullable = false) + private LocalDateTime readAt; + + private ProjectNoticeRead(ProjectNotice notice, Users user) { + this.notice = notice; + this.user = user; + this.readAt = LocalDateTime.now(); + } + + public static ProjectNoticeRead create(ProjectNotice notice, Users user) { + return new ProjectNoticeRead(notice, user); + } + + public void refreshReadAt() { + this.readAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/slatto/domain/project/entity/ProjectPin.java b/src/main/java/com/slatto/domain/project/entity/ProjectPin.java new file mode 100644 index 00000000..fc367441 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/entity/ProjectPin.java @@ -0,0 +1,49 @@ +package com.slatto.domain.project.entity; + +import com.slatto.domain.common.entity.BaseEntity; +import com.slatto.domain.user.entity.Users; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "project_pin", + uniqueConstraints = @UniqueConstraint( + name = "uk_project_pin_user_project", + columnNames = {"user_id", "project_id"} + ) +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectPin extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Column(name = "pinned_at", nullable = false) + private LocalDateTime pinnedAt; + + private ProjectPin(Users user, Project project) { + this.user = user; + this.project = project; + this.pinnedAt = LocalDateTime.now(); + } + + public static ProjectPin create(Users user, Project project) { + return new ProjectPin(user, project); + } +} diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java index 140431a5..ec5be074 100644 --- a/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java +++ b/src/main/java/com/slatto/domain/project/repository/ProjectFileRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -20,7 +21,23 @@ public interface ProjectFileRepository extends JpaRepository and (:keyword is null or :keyword = '' or lower(pf.fileName) like lower(concat('%', :keyword, '%'))) - and (:cursor is null or pf.id < :cursor) + and ( + :cursor is null + or ( + :cursorPinnedAt is not null + and ( + (pf.pinnedAt is not null + and (pf.pinnedAt < :cursorPinnedAt + or (pf.pinnedAt = :cursorPinnedAt and pf.id < :cursor))) + or pf.pinnedAt is null + ) + ) + or ( + :cursorPinnedAt is null + and pf.pinnedAt is null + and pf.id < :cursor + ) + ) order by case when pf.pinnedAt is null then 1 else 0 end asc, pf.pinnedAt desc, @@ -30,6 +47,7 @@ List findActiveFilesByCursor( @Param("projectId") Long projectId, @Param("keyword") String keyword, @Param("cursor") Long cursor, + @Param("cursorPinnedAt") LocalDateTime cursorPinnedAt, Pageable pageable ); 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 e3ecc32a..210eb9c8 100644 --- a/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java +++ b/src/main/java/com/slatto/domain/project/repository/ProjectMemberRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -58,17 +59,38 @@ List findActiveMembersByProjectId( select pm from ProjectMember pm join fetch pm.project p + left join ProjectPin pp on pp.project = p and pp.user.id = :userId 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 + and ( + :cursor is null + or ( + :cursorPinnedAt is not null + and ( + (pp.pinnedAt is not null + and (pp.pinnedAt < :cursorPinnedAt + or (pp.pinnedAt = :cursorPinnedAt and p.id < :cursor))) + or pp.pinnedAt is null + ) + ) + or ( + :cursorPinnedAt is null + and pp.pinnedAt is null + and p.id < :cursor + ) + ) + order by + case when pp.pinnedAt is null then 1 else 0 end asc, + pp.pinnedAt desc, + p.id desc """) List findJoinedProjectsByCursor( @Param("userId") Long userId, @Param("status") ProjectStatus status, @Param("cursor") Long cursor, + @Param("cursorPinnedAt") LocalDateTime cursorPinnedAt, Pageable pageable ); } diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectNoticeReadRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectNoticeReadRepository.java new file mode 100644 index 00000000..1ab6005f --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectNoticeReadRepository.java @@ -0,0 +1,40 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectNoticeRead; +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.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProjectNoticeReadRepository extends JpaRepository { + + Optional findByNoticeIdAndUserId(Long noticeId, Long userId); + + @Modifying + @Query(value = """ + INSERT INTO project_notice_read + (notice_id, user_id, read_at, created_at, updated_at) + VALUES (:noticeId, :userId, NOW(6), NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE read_at = NOW(6), updated_at = NOW(6) + """, nativeQuery = true) + void upsertRead( + @Param("noticeId") Long noticeId, + @Param("userId") Long userId + ); + + @Query(""" + select pnr + from ProjectNoticeRead pnr + join fetch pnr.notice pn + where pnr.user.id = :userId + and pn.id in :noticeIds + """) + List findAllByUserIdAndNoticeIds( + @Param("userId") Long userId, + @Param("noticeIds") Collection noticeIds + ); +} diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectPinRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectPinRepository.java new file mode 100644 index 00000000..7114310f --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectPinRepository.java @@ -0,0 +1,41 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectPin; +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.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProjectPinRepository extends JpaRepository { + + Optional findByUserIdAndProjectId(Long userId, Long projectId); + + @Modifying + @Query(value = """ + INSERT IGNORE INTO project_pin + (user_id, project_id, pinned_at, created_at, updated_at) + VALUES (:userId, :projectId, NOW(6), NOW(6), NOW(6)) + """, nativeQuery = true) + void insertIgnore( + @Param("userId") Long userId, + @Param("projectId") Long projectId + ); + + boolean existsByUserIdAndProjectId(Long userId, Long projectId); + + @Query(""" + select pp + from ProjectPin pp + join fetch pp.project p + where pp.user.id = :userId + and p.id in :projectIds + """) + List findAllByUserIdAndProjectIds( + @Param("userId") Long userId, + @Param("projectIds") Collection projectIds + ); +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java index 09ea1f89..a2248c45 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectFileService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectFileService.java @@ -3,6 +3,7 @@ import com.slatto.domain.project.dto.ProjectFileDownloadResponse; import com.slatto.domain.project.dto.ProjectFileResponse; import com.slatto.domain.project.dto.ProjectFileListResponse; +import com.slatto.domain.project.dto.ProjectFilePinResponse; import com.slatto.domain.project.dto.ProjectFileUpdateRequest; import com.slatto.domain.project.dto.ProjectFileUploadRequest; import com.slatto.domain.project.entity.Project; @@ -24,6 +25,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import java.util.Map; @@ -63,10 +65,12 @@ public ProjectFileListResponse getProjectFiles( projectAccessValidator.validateProjectAccess(projectId, currentUserId); int pageSize = normalizePageSize(size); + LocalDateTime cursorPinnedAt = getFileCursorPinnedAt(projectId, cursor); List projectFiles = projectFileRepository.findActiveFilesByCursor( projectId, keyword, cursor, + cursorPinnedAt, PageRequest.of(0, pageSize + 1) ); @@ -115,10 +119,6 @@ public ProjectFileResponse uploadProjectFile( storageKey ); - if (Boolean.TRUE.equals(request.getIsPinned())) { - projectFile.pin(); - } - ProjectFile savedFile = projectFileRepository.save(projectFile); return toResponse(savedFile); @@ -166,6 +166,38 @@ public ProjectFileResponse updateProjectFile( return toResponse(projectFile); } + @Transactional + public ProjectFilePinResponse pinProjectFile( + Long projectId, + Long fileId, + Long currentUserId + ) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectFile projectFile = getActiveFileOrThrow(projectId, fileId); + + validateFileEditable(projectFile, currentMember, currentUserId); + projectFile.pin(); + + return toPinResponse(projectFile); + } + + @Transactional + public ProjectFilePinResponse unpinProjectFile( + Long projectId, + Long fileId, + Long currentUserId + ) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectFile projectFile = getActiveFileOrThrow(projectId, fileId); + + validateFileEditable(projectFile, currentMember, currentUserId); + projectFile.unpin(); + + return toPinResponse(projectFile); + } + @Transactional public void deleteProjectFile(Long projectId, Long fileId, Long currentUserId) { projectAccessValidator.getProjectOrThrow(projectId); @@ -208,17 +240,6 @@ private void updateProjectFileInfo(ProjectFile projectFile, ProjectFileUpdateReq if (request.getIsFinal() != null) { projectFile.changeFinal(request.getIsFinal()); } - - if (request.getIsPinned() == null) { - return; - } - - if (Boolean.TRUE.equals(request.getIsPinned())) { - projectFile.pin(); - return; - } - - projectFile.unpin(); } private void validateFile(MultipartFile file, String fileName) { @@ -316,4 +337,20 @@ private ProjectFileResponse toResponse(ProjectFile projectFile) { .updatedAt(projectFile.getUpdatedAt()) .build(); } + + private LocalDateTime getFileCursorPinnedAt(Long projectId, Long cursor) { + if (cursor == null) { + return null; + } + + return getActiveFileOrThrow(projectId, cursor).getPinnedAt(); + } + + private ProjectFilePinResponse toPinResponse(ProjectFile projectFile) { + return ProjectFilePinResponse.builder() + .id(projectFile.getId()) + .isPinned(projectFile.isPinned()) + .pinnedAt(projectFile.getPinnedAt()) + .build(); + } } diff --git a/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java b/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java index 0159f51f..9779c82a 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java @@ -2,21 +2,27 @@ import com.slatto.domain.project.dto.ProjectNoticeCreateRequest; import com.slatto.domain.project.dto.ProjectNoticeListResponse; +import com.slatto.domain.project.dto.ProjectNoticeReadResponse; import com.slatto.domain.project.dto.ProjectNoticeResponse; import com.slatto.domain.project.dto.ProjectNoticeUpdateRequest; import com.slatto.domain.project.entity.Project; import com.slatto.domain.project.entity.ProjectMember; import com.slatto.domain.project.entity.ProjectNotice; +import com.slatto.domain.project.entity.ProjectNoticeRead; import com.slatto.domain.project.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectNoticeReadRepository; import com.slatto.domain.project.repository.ProjectNoticeRepository; import com.slatto.domain.user.entity.Users; 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.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -27,6 +33,7 @@ public class ProjectNoticeService { private static final int MAX_PAGE_SIZE = 50; private final ProjectNoticeRepository projectNoticeRepository; + private final ProjectNoticeReadRepository projectNoticeReadRepository; private final ProjectAccessValidator projectAccessValidator; public ProjectNoticeListResponse getProjectNotices( @@ -49,9 +56,13 @@ public ProjectNoticeListResponse getProjectNotices( List currentPageNotices = projectNotices.stream() .limit(pageSize) .toList(); + Map readByNoticeId = getReadByNoticeId(currentUserId, currentPageNotices); List items = currentPageNotices.stream() - .map(this::toResponse) + .map(projectNotice -> toResponse( + projectNotice, + readByNoticeId.getOrDefault(projectNotice.getId(), false) + )) .toList(); Long nextCursor = hasNext && !items.isEmpty() @@ -82,7 +93,7 @@ public ProjectNoticeResponse createProjectNotice( ); ProjectNotice savedNotice = projectNoticeRepository.save(projectNotice); - return toResponse(savedNotice); + return toResponse(savedNotice, false); } @Transactional @@ -98,8 +109,10 @@ public ProjectNoticeResponse updateProjectNotice( validateNoticeEditable(projectNotice, currentMember, currentUserId); projectNotice.update(request.getTitle(), request.getContent()); + boolean isRead = projectNoticeReadRepository.findByNoticeIdAndUserId(noticeId, currentUserId) + .isPresent(); - return toResponse(projectNotice); + return toResponse(projectNotice, isRead); } @Transactional @@ -112,6 +125,20 @@ public void deleteProjectNotice(Long projectId, Long noticeId, Long currentUserI projectNotice.delete(); } + @Transactional + public ProjectNoticeReadResponse readProjectNotice(Long projectId, Long noticeId, Long currentUserId) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + getActiveNoticeOrThrow(projectId, noticeId); + + projectNoticeReadRepository.upsertRead(noticeId, currentUserId); + ProjectNoticeRead projectNoticeRead = projectNoticeReadRepository + .findByNoticeIdAndUserId(noticeId, currentUserId) + .orElseThrow(() -> new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR)); + + return toReadResponse(projectNoticeRead); + } + private ProjectNotice getActiveNoticeOrThrow(Long projectId, Long noticeId) { return projectNoticeRepository.findActiveNoticeByProjectIdAndNoticeId(projectId, noticeId) .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_NOTICE_NOT_FOUND)); @@ -129,7 +156,7 @@ private void validateNoticeEditable( throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); } - private ProjectNoticeResponse toResponse(ProjectNotice projectNotice) { + private ProjectNoticeResponse toResponse(ProjectNotice projectNotice, boolean isRead) { Users writer = projectNotice.getWriter(); return ProjectNoticeResponse.builder() @@ -140,11 +167,38 @@ private ProjectNoticeResponse toResponse(ProjectNotice projectNotice) { .id(writer.getId()) .nickname(writer.getNickname()) .build()) + .isRead(isRead) .createdAt(projectNotice.getCreatedAt()) .updatedAt(projectNotice.getUpdatedAt()) .build(); } + private ProjectNoticeReadResponse toReadResponse(ProjectNoticeRead projectNoticeRead) { + return ProjectNoticeReadResponse.builder() + .id(projectNoticeRead.getNotice().getId()) + .isRead(true) + .readAt(projectNoticeRead.getReadAt()) + .build(); + } + + private Map getReadByNoticeId(Long userId, List projectNotices) { + List noticeIds = projectNotices.stream() + .map(ProjectNotice::getId) + .toList(); + + if (noticeIds.isEmpty()) { + return Map.of(); + } + + return projectNoticeReadRepository.findAllByUserIdAndNoticeIds(userId, noticeIds) + .stream() + .collect(Collectors.toMap( + projectNoticeRead -> projectNoticeRead.getNotice().getId(), + projectNoticeRead -> true, + (left, right) -> left + )); + } + private int normalizePageSize(int size) { if (size <= 0) { return DEFAULT_PAGE_SIZE; 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 f8907b02..a1f86034 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -4,19 +4,23 @@ 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.ProjectPinResponse; 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.ProjectPin; 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.ProjectPinRepository; 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.domain.video.repository.VideoRepository; import com.slatto.global.exception.BaseException; import com.slatto.global.response.code.CommonErrorCode; import lombok.RequiredArgsConstructor; @@ -26,7 +30,9 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -40,8 +46,10 @@ public class ProjectService { private final ProjectRepository projectRepository; private final ProjectMemberRepository projectMemberRepository; + private final ProjectPinRepository projectPinRepository; private final ProjectUserRoleRepository projectUserRoleRepository; private final UserRepository userRepository; + private final VideoRepository videoRepository; private final ProjectConverter projectConverter; private final ProjectAccessValidator projectAccessValidator; @@ -70,10 +78,12 @@ public ProjectListResponse getProjects( validateActiveUserExists(currentUserId); int pageSize = normalizePageSize(size); + LocalDateTime cursorPinnedAt = getProjectCursorPinnedAt(currentUserId, cursor); List projectMembers = projectMemberRepository.findJoinedProjectsByCursor( currentUserId, status, cursor, + cursorPinnedAt, PageRequest.of(0, pageSize + 1) ); @@ -82,20 +92,27 @@ public ProjectListResponse getProjects( .limit(pageSize) .toList(); + Map> roleNamesByMemberId = getRoleNamesByMemberId(currentPageMembers); + Map previewImageUrlByProjectId = getPreviewImageUrlByProjectId(currentPageMembers); + Map pinnedAtByProjectId = getPinnedAtByProjectId(currentUserId, currentPageMembers); + + Long nextCursor = hasNext && !currentPageMembers.isEmpty() + ? currentPageMembers.get(currentPageMembers.size() - 1).getProject().getId() + : null; + List items = currentPageMembers.stream() - .map(ProjectMember::getProject) - .map(project -> projectConverter.toSummary( - project, - countActiveMembers(project), - getMemberPreviewImageUrls(project), - resolveLastActivityAt(project) + .map(projectMember -> projectConverter.toSummary( + projectMember.getProject(), + countActiveMembers(projectMember.getProject()), + getMemberPreviewImageUrls(projectMember.getProject()), + roleNamesByMemberId.getOrDefault(projectMember.getId(), List.of()), + previewImageUrlByProjectId.get(projectMember.getProject().getId()), + pinnedAtByProjectId.get(projectMember.getProject().getId()), + projectMember.getPermission(), + resolveLastActivityAt(projectMember.getProject()) )) .toList(); - Long nextCursor = hasNext && !items.isEmpty() - ? items.get(items.size() - 1).getId() - : null; - return projectConverter.toListResponse(items, nextCursor, hasNext); } @@ -107,15 +124,41 @@ public ProjectDetailResponse getProject(Long projectId, Long currentUserId) { .stream() .map(ProjectUserRole::getRoleName) .toList(); + ProjectPin projectPin = projectPinRepository.findByUserIdAndProjectId(currentUserId, projectId) + .orElse(null); return projectConverter.toDetailResponse( project, currentMember, roleNames, + projectPin, countActiveMembers(project) ); } + @Transactional + public ProjectPinResponse pinProject(Long projectId, Long currentUserId) { + Project project = projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + + projectPinRepository.insertIgnore(currentUserId, projectId); + ProjectPin projectPin = projectPinRepository.findByUserIdAndProjectId(currentUserId, projectId) + .orElseThrow(() -> new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR)); + + return projectConverter.toPinResponse(project.getId(), projectPin.getPinnedAt()); + } + + @Transactional + public ProjectPinResponse unpinProject(Long projectId, Long currentUserId) { + Project project = projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + + projectPinRepository.findByUserIdAndProjectId(currentUserId, projectId) + .ifPresent(projectPinRepository::delete); + + return projectConverter.toPinResponse(project.getId(), null); + } + @Transactional public ProjectResponse updateProject( Long projectId, @@ -196,6 +239,65 @@ private List getMemberPreviewImageUrls(Project project) { .toList(); } + 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 Map getPreviewImageUrlByProjectId(List projectMembers) { + List projectIds = projectMembers.stream() + .map(ProjectMember::getProject) + .map(Project::getId) + .toList(); + + if (projectIds.isEmpty()) { + return Map.of(); + } + + return videoRepository.findLatestThumbnailUrlsByProjectIds(projectIds); + } + + private Map getPinnedAtByProjectId(Long userId, List projectMembers) { + List projectIds = projectMembers.stream() + .map(ProjectMember::getProject) + .map(Project::getId) + .toList(); + + if (projectIds.isEmpty()) { + return Map.of(); + } + + return projectPinRepository.findAllByUserIdAndProjectIds(userId, projectIds) + .stream() + .collect(Collectors.toMap( + projectPin -> projectPin.getProject().getId(), + ProjectPin::getPinnedAt + )); + } + + private LocalDateTime getProjectCursorPinnedAt(Long userId, Long cursor) { + if (cursor == null) { + return null; + } + + return projectPinRepository.findByUserIdAndProjectId(userId, cursor) + .map(ProjectPin::getPinnedAt) + .orElse(null); + } + private LocalDateTime resolveLastActivityAt(Project project) { return project.getUpdatedAt() != null ? project.getUpdatedAt() : project.getCreatedAt(); } diff --git a/src/main/java/com/slatto/domain/video/repository/VideoRepository.java b/src/main/java/com/slatto/domain/video/repository/VideoRepository.java index cb213321..94f503a1 100644 --- a/src/main/java/com/slatto/domain/video/repository/VideoRepository.java +++ b/src/main/java/com/slatto/domain/video/repository/VideoRepository.java @@ -7,7 +7,9 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; @Repository @RequiredArgsConstructor @@ -61,4 +63,29 @@ public List