diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java new file mode 100644 index 00000000..51dda540 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/controller/ProjectNoticeController.java @@ -0,0 +1,101 @@ +package com.slatto.domain.project.controller; + +import com.slatto.domain.project.dto.ProjectNoticeCreateRequest; +import com.slatto.domain.project.dto.ProjectNoticeListResponse; +import com.slatto.domain.project.dto.ProjectNoticeResponse; +import com.slatto.domain.project.dto.ProjectNoticeUpdateRequest; +import com.slatto.domain.project.service.ProjectNoticeService; +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 Notice", description = "프로젝트 공지 API") +@RestController +@RequiredArgsConstructor +@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, + @PathVariable Long projectId, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + ProjectNoticeListResponse response = projectNoticeService.getProjectNotices( + projectId, + currentUserId, + cursor, + size + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 공지 등록") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createProjectNotice( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId, + @Valid @RequestBody ProjectNoticeCreateRequest request + ) { + ProjectNoticeResponse response = projectNoticeService.createProjectNotice( + projectId, + currentUserId, + request + ); + + return ApiResponse.success(CommonSuccessCode.CREATED, response); + } + + @Operation(summary = "프로젝트 공지 수정") + @PatchMapping("/{noticeId}") + public ApiResponse updateProjectNotice( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long noticeId, + @Valid @RequestBody ProjectNoticeUpdateRequest request + ) { + ProjectNoticeResponse response = projectNoticeService.updateProjectNotice( + projectId, + noticeId, + currentUserId, + request + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + + @Operation(summary = "프로젝트 공지 삭제") + @DeleteMapping("/{noticeId}") + public ApiResponse deleteProjectNotice( + @RequestHeader(CURRENT_USER_ID_HEADER) Long currentUserId, + @PathVariable Long projectId, + @PathVariable Long noticeId + ) { + projectNoticeService.deleteProjectNotice(projectId, noticeId, currentUserId); + + return ApiResponse.success(CommonSuccessCode.OK, null); + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeCreateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeCreateRequest.java new file mode 100644 index 00000000..6527bdf8 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeCreateRequest.java @@ -0,0 +1,19 @@ +package com.slatto.domain.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectNoticeCreateRequest { + + @NotBlank(message = "공지 제목은 필수입니다.") + @Size(max = 100, message = "공지 제목은 최대 100자까지 입력할 수 있습니다.") + private String title; + + @NotBlank(message = "공지 내용은 필수입니다.") + private String content; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeListResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeListResponse.java new file mode 100644 index 00000000..c343bda0 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeListResponse.java @@ -0,0 +1,17 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class ProjectNoticeListResponse { + + private List items; + + private Long nextCursor; + + private Boolean hasNext; +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java new file mode 100644 index 00000000..932d4d84 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeResponse.java @@ -0,0 +1,32 @@ +package com.slatto.domain.project.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ProjectNoticeResponse { + + private Long id; + + private String title; + + private String content; + + private WriterSummary writer; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @Getter + @Builder + public static class WriterSummary { + + private Long id; + + private String nickname; + } +} diff --git a/src/main/java/com/slatto/domain/project/dto/ProjectNoticeUpdateRequest.java b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeUpdateRequest.java new file mode 100644 index 00000000..1024b9c9 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/dto/ProjectNoticeUpdateRequest.java @@ -0,0 +1,19 @@ +package com.slatto.domain.project.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectNoticeUpdateRequest { + + @NotBlank(message = "공지 제목은 필수입니다.") + @Size(max = 100, message = "공지 제목은 최대 100자까지 입력할 수 있습니다.") + private String title; + + @NotBlank(message = "공지 내용은 필수입니다.") + private String content; +} 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 3d925a46..4156de4d 100644 --- a/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java +++ b/src/main/java/com/slatto/domain/project/entity/ProjectNotice.java @@ -28,7 +28,7 @@ public class ProjectNotice extends BaseEntity { @JoinColumn(name = "writer_id", nullable = false) private Users writer; - @Column(name = "title", nullable = false, length = 255) + @Column(name = "title", nullable = false, length = 100) private String title; @Column(name = "content", nullable = false, columnDefinition = "TEXT") @@ -37,4 +37,27 @@ public class ProjectNotice extends BaseEntity { @Column(name = "deleted_at", nullable = true) private LocalDateTime deletedAt; + private ProjectNotice(Project project, Users writer, String title, String content) { + this.project = project; + this.writer = writer; + this.title = title; + this.content = content; + } + + public static ProjectNotice create(Project project, Users writer, String title, String content) { + return new ProjectNotice(project, writer, title, content); + } + + public boolean isWrittenBy(Long userId) { + return writer.getId().equals(userId); + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java index 627dff0b..feae0c74 100644 --- a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java +++ b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java @@ -12,6 +12,7 @@ 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_NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "PROJECT_NOTICE404", "프로젝트 공지를 찾을 수 없습니다."), PROJECT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PROJECT403", "프로젝트 접근 권한이 없습니다."), PROJECT_ADMIN_REQUIRED(HttpStatus.FORBIDDEN, "PROJECT_ADMIN403", "프로젝트 관리자 권한이 필요합니다."), PROJECT_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "PROJECT409", "무료 계정은 최대 5개의 프로젝트를 생성할 수 있습니다."); diff --git a/src/main/java/com/slatto/domain/project/repository/ProjectNoticeRepository.java b/src/main/java/com/slatto/domain/project/repository/ProjectNoticeRepository.java new file mode 100644 index 00000000..6d25f255 --- /dev/null +++ b/src/main/java/com/slatto/domain/project/repository/ProjectNoticeRepository.java @@ -0,0 +1,41 @@ +package com.slatto.domain.project.repository; + +import com.slatto.domain.project.entity.ProjectNotice; +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 ProjectNoticeRepository extends JpaRepository { + + @Query(""" + select pn + from ProjectNotice pn + join fetch pn.writer w + where pn.project.id = :projectId + and pn.deletedAt is null + and (:cursor is null or pn.id < :cursor) + order by pn.id desc + """) + List findActiveNoticesByCursor( + @Param("projectId") Long projectId, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + select pn + from ProjectNotice pn + join fetch pn.writer w + where pn.project.id = :projectId + and pn.id = :noticeId + and pn.deletedAt is null + """) + Optional findActiveNoticeByProjectIdAndNoticeId( + @Param("projectId") Long projectId, + @Param("noticeId") Long noticeId + ); +} diff --git a/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java b/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java new file mode 100644 index 00000000..0159f51f --- /dev/null +++ b/src/main/java/com/slatto/domain/project/service/ProjectNoticeService.java @@ -0,0 +1,155 @@ +package com.slatto.domain.project.service; + +import com.slatto.domain.project.dto.ProjectNoticeCreateRequest; +import com.slatto.domain.project.dto.ProjectNoticeListResponse; +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.exception.ProjectErrorCode; +import com.slatto.domain.project.repository.ProjectNoticeRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectNoticeService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final ProjectNoticeRepository projectNoticeRepository; + private final ProjectAccessValidator projectAccessValidator; + + public ProjectNoticeListResponse getProjectNotices( + Long projectId, + Long currentUserId, + Long cursor, + int size + ) { + projectAccessValidator.getProjectOrThrow(projectId); + projectAccessValidator.validateProjectAccess(projectId, currentUserId); + + int pageSize = normalizePageSize(size); + List projectNotices = projectNoticeRepository.findActiveNoticesByCursor( + projectId, + cursor, + PageRequest.of(0, pageSize + 1) + ); + + boolean hasNext = projectNotices.size() > pageSize; + List currentPageNotices = projectNotices.stream() + .limit(pageSize) + .toList(); + + List items = currentPageNotices.stream() + .map(this::toResponse) + .toList(); + + Long nextCursor = hasNext && !items.isEmpty() + ? items.get(items.size() - 1).getId() + : null; + + return ProjectNoticeListResponse.builder() + .items(items) + .nextCursor(nextCursor) + .hasNext(hasNext) + .build(); + } + + @Transactional + public ProjectNoticeResponse createProjectNotice( + Long projectId, + Long currentUserId, + ProjectNoticeCreateRequest request + ) { + Project project = projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + + ProjectNotice projectNotice = ProjectNotice.create( + project, + currentMember.getUser(), + request.getTitle(), + request.getContent() + ); + ProjectNotice savedNotice = projectNoticeRepository.save(projectNotice); + + return toResponse(savedNotice); + } + + @Transactional + public ProjectNoticeResponse updateProjectNotice( + Long projectId, + Long noticeId, + Long currentUserId, + ProjectNoticeUpdateRequest request + ) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectNotice projectNotice = getActiveNoticeOrThrow(projectId, noticeId); + validateNoticeEditable(projectNotice, currentMember, currentUserId); + + projectNotice.update(request.getTitle(), request.getContent()); + + return toResponse(projectNotice); + } + + @Transactional + public void deleteProjectNotice(Long projectId, Long noticeId, Long currentUserId) { + projectAccessValidator.getProjectOrThrow(projectId); + ProjectMember currentMember = projectAccessValidator.getCurrentMemberOrThrow(projectId, currentUserId); + ProjectNotice projectNotice = getActiveNoticeOrThrow(projectId, noticeId); + validateNoticeEditable(projectNotice, currentMember, currentUserId); + + projectNotice.delete(); + } + + private ProjectNotice getActiveNoticeOrThrow(Long projectId, Long noticeId) { + return projectNoticeRepository.findActiveNoticeByProjectIdAndNoticeId(projectId, noticeId) + .orElseThrow(() -> new BaseException(ProjectErrorCode.PROJECT_NOTICE_NOT_FOUND)); + } + + private void validateNoticeEditable( + ProjectNotice projectNotice, + ProjectMember currentMember, + Long currentUserId + ) { + if (projectNotice.isWrittenBy(currentUserId) || currentMember.isAdmin()) { + return; + } + + throw new BaseException(ProjectErrorCode.PROJECT_ACCESS_DENIED); + } + + private ProjectNoticeResponse toResponse(ProjectNotice projectNotice) { + Users writer = projectNotice.getWriter(); + + return ProjectNoticeResponse.builder() + .id(projectNotice.getId()) + .title(projectNotice.getTitle()) + .content(projectNotice.getContent()) + .writer(ProjectNoticeResponse.WriterSummary.builder() + .id(writer.getId()) + .nickname(writer.getNickname()) + .build()) + .createdAt(projectNotice.getCreatedAt()) + .updatedAt(projectNotice.getUpdatedAt()) + .build(); + } + + private int normalizePageSize(int size) { + if (size <= 0) { + return DEFAULT_PAGE_SIZE; + } + + return Math.min(size, MAX_PAGE_SIZE); + } +}