diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java new file mode 100644 index 0000000..32d1261 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/command/NoticeCommandController.java @@ -0,0 +1,109 @@ +package org.withtime.be.withtimebe.domain.notice.controller.command; + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.converter.NoticeConverter; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.dto.response.NoticeResponseDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.service.command.NoticeCommandService; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/notices") +public class NoticeCommandController { + + private final NoticeCommandService noticeCommandService; + + @Operation(summary = "공지사항 생성 API Only Admin by 피우", description = "공지사항 생성 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """) + }) + @PostMapping + public DefaultResponse createNotice( + @RequestBody @Valid NoticeRequestDTO.CreateNotice request, + @AuthenticatedMember Member member + ) { + Notice result = noticeCommandService.createNotice(request, member); + NoticeResponseDTO.Notice response = NoticeConverter.toNotice(result); + return DefaultResponse.created(response); + } + + @Operation(summary = "공지사항 수정 API Only Admin by 피우", description = "공지사항 수정 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse(responseCode = "404", + description = """ + - NOTICE404_2 : "해당하는 공지사항을 찾을 수 없습니다." + """) + }) + @PutMapping("/{noticeId}") + public DefaultResponse updateNotice( + @PathVariable Long noticeId, + @RequestBody @Valid NoticeRequestDTO.UpdateNotice request + ) { + Notice result = noticeCommandService.updateNotice(request, noticeId); + NoticeResponseDTO.Notice response = NoticeConverter.toNotice(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "공지사항 삭제 API Only Admin by 피우", description = "공지사항 삭제 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "204", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse(responseCode = "404", + description = """ + - NOTICE404_2 : "해당하는 공지사항을 찾을 수 없습니다." + """) + }) + @DeleteMapping("/{noticeId}") + public DefaultResponse softDeleteNotice(@PathVariable Long noticeId) { + noticeCommandService.softDeleteNotice(noticeId); + return DefaultResponse.noContent(); + } + + @Operation(summary = "삭제한 공지사항 되돌리기 API Only Admin by 피우", description = "삭제한 공지사항을 되돌리는 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse(responseCode = "404", + description = """ + - NOTICE404_2 : "해당하는 공지사항을 찾을 수 없습니다." + """) + }) + @PatchMapping("/{noticeId}") + public DefaultResponse recoverDeletedNotice(@PathVariable Long noticeId) { + Notice result = noticeCommandService.recoverDeletedNotice(noticeId); + NoticeResponseDTO.Notice response = NoticeConverter.toNotice(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java new file mode 100644 index 0000000..423660f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/controller/query/NoticeQueryController.java @@ -0,0 +1,130 @@ +package org.withtime.be.withtimebe.domain.notice.controller.query; + + +import org.namul.api.payload.response.DefaultResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.converter.NoticeConverter; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.dto.response.NoticeResponseDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.service.query.NoticeQueryService; +import org.withtime.be.withtimebe.global.annotation.SwaggerPageable; +import org.withtime.be.withtimebe.global.security.annotation.AuthenticatedMember; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class NoticeQueryController { + + private final NoticeQueryService noticeQueryService; + + @Operation(summary = "공지사항 전체 조회 API by 피우", description = "공지사항 전체 조회 API입니다. (검색어 X)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse( + responseCode = "404", + description = """ + - NOTICE404_1 : 해당하는 공지사항 유형을 찾을 수 없습니다." + """) + }) + @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") + @SwaggerPageable + @GetMapping("/notices") + public DefaultResponse findNoticeList( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestParam String noticeCategory + ) { + NoticeRequestDTO.FindNoticeList request = NoticeConverter.toFindNoticeList(pageable, noticeCategory); + Page result = noticeQueryService.findNoticeList(request); + NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "공지사항 검색어 전체 조회 API by 피우", description = "공지사항 전체 조회 API입니다. (검색어 O)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse( + responseCode = "404", + description = """ + - NOTICE404_1 : 해당하는 공지사항 유형을 찾을 수 없습니다." + """) + }) + @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") + @SwaggerPageable + @GetMapping("/notices/search") + public DefaultResponse findNoticeListByKeyword( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestParam String keyword, + @RequestParam String noticeCategory + ) { + NoticeRequestDTO.FindNoticeListByKeyword request = NoticeConverter.toFindNoticeListByKeyword(pageable, keyword, noticeCategory); + Page result = noticeQueryService.findNoticeListByKeyword(request); + NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); + return DefaultResponse.ok(response); + } + + @Operation(summary = "공지사항 상세 조회 API by 피우", description = "공지사항 상세 조회 API입니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse( + responseCode = "403", + description = """ + - NOTICE403_1 : 삭제된 공지사항을 열람할 Admin 권한이 없습니다. + """), + @ApiResponse( + responseCode = "404", + description = """ + - NOTICE404_2 : 해당하는 공지사항을 찾을 수 없습니다. + """) + }) + @GetMapping("/notices/{noticeId}") + public DefaultResponse findNoticeDetail( + @PathVariable("noticeId") Long noticeId, + @AuthenticatedMember Member member + ) { + NoticeRequestDTO.FindNoticeDetail request = NoticeConverter.toFindNoticeDetail(noticeId, member); + Notice result = noticeQueryService.findNoticeDetail(request); + NoticeResponseDTO.NoticeDetail response = NoticeConverter.toNoticeDetail(result, member); + return DefaultResponse.ok(response); + } + + @Operation(summary = "삭제된 공지사항 전체 조회 API Only Admin by 피우", description = "삭제된 공지사항 상세 조회 API입니다. 어드민만 사용 가능합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공입니다."), + @ApiResponse(responseCode = "403", + description = """ + - COMMON403 : "Admin 권한이 없음을 의미합니다." + """), + @ApiResponse( + responseCode = "404", + description = """ + - NOTICE404_1 : "해당하는 공지사항 유형을 찾을 수 없습니다." + """) + }) + @Parameter(name = "noticeCategory", description = "SYSTEM / SERVICE") + @SwaggerPageable + @GetMapping("/admin/notices/trash") + public DefaultResponse findTrashNoticeList( + @PageableDefault(page = 0, size = 10) Pageable pageable, + @RequestParam String noticeCategory + ) { + NoticeRequestDTO.FindNoticeList request = NoticeConverter.toFindNoticeList(pageable, noticeCategory); + Page result = noticeQueryService.findTrashNoticeList(request); + NoticeResponseDTO.NoticeList response = NoticeConverter.toNoticeList(result); + return DefaultResponse.ok(response); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java new file mode 100644 index 0000000..5d7276c --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/converter/NoticeConverter.java @@ -0,0 +1,113 @@ +package org.withtime.be.withtimebe.domain.notice.converter; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.enums.Role; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.dto.response.NoticeResponseDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; +import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; +import org.withtime.be.withtimebe.global.error.exception.NoticeException; + +public class NoticeConverter { + + // Request DTO : 전체 조회 (Controller -> Service) + public static NoticeRequestDTO.FindNoticeList toFindNoticeList(Pageable pageable, String type) { + + NoticeCategory noticeCategory; + + try { + noticeCategory = NoticeCategory.valueOf(type); + } catch (IllegalArgumentException e) { + throw new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_NOT_FOUND); + } + + return NoticeRequestDTO.FindNoticeList.builder() + .pageable(pageable) + .noticeCategory(noticeCategory) + .build(); + } + + // Request DTO : 검색어 전체 조회 (Controller -> Service) + public static NoticeRequestDTO.FindNoticeListByKeyword toFindNoticeListByKeyword(Pageable pageable, String keyword, String type) { + + NoticeCategory noticeCategory; + + try { + noticeCategory = NoticeCategory.valueOf(type); + } catch (IllegalArgumentException e) { + throw new NoticeException(NoticeErrorCode.NOTICE_CATEGORY_NOT_FOUND); + } + + return NoticeRequestDTO.FindNoticeListByKeyword.builder() + .pageable(pageable) + .keyword(keyword) + .noticeCategory(noticeCategory) + .build(); + } + + // Request : 상세 조회 요청 (Controller -> Service) DTO + public static NoticeRequestDTO.FindNoticeDetail toFindNoticeDetail(Long noticeId, Member member) { + + return NoticeRequestDTO.FindNoticeDetail.builder() + .noticeId(noticeId) + .member(member) + .build(); + } + + // Response DTO : NoticeResponseDTO.NoticeList + public static NoticeResponseDTO.NoticeList toNoticeList(Page noticePage) { + + List noticeList = noticePage.getContent().stream() + .map(NoticeConverter::toNotice) + .toList(); + + return NoticeResponseDTO.NoticeList.builder() + .noticeList(noticeList) + .totalPages(noticePage.getTotalPages()) + .currentPage(noticePage.getNumber()) + .currentSize(noticePage.getNumberOfElements()) + .hasNextPage(noticePage.hasNext()) + .build(); + } + + // Response DTO : NoticeResponseDTO.Notice + public static NoticeResponseDTO.Notice toNotice(Notice notice) { + + return NoticeResponseDTO.Notice.builder() + .noticeId(notice.getId()) + .title(notice.getTitle()) + .isPinned(notice.getIsPinned()) + .createdAt(notice.getCreatedAt()) + .build(); + } + + // Response : NoticeDetail(DTO)로 변환 + public static NoticeResponseDTO.NoticeDetail toNoticeDetail(Notice notice, Member member) { + + boolean hasAdminAuth = member != null && member.getRole().equals(Role.ADMIN); + + return NoticeResponseDTO.NoticeDetail.builder() + .noticeId(notice.getId()) + .title(notice.getTitle()) + .content(notice.getContent()) + .isPinned(notice.getIsPinned()) + .hasAdminAuth(hasAdminAuth) + .createdAt(notice.getCreatedAt()) + .build(); + } + + public static Notice toNoticeEntity(NoticeRequestDTO.CreateNotice request, Member member) { + + return Notice.builder() + .member(member) + .title(request.title()) + .content(request.content()) + .isPinned(request.isPinned()) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java new file mode 100644 index 0000000..b6e28b5 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/request/NoticeRequestDTO.java @@ -0,0 +1,49 @@ +package org.withtime.be.withtimebe.domain.notice.dto.request; + +import org.springframework.data.domain.Pageable; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +public record NoticeRequestDTO() { + + @Builder + public record FindNoticeList( + Pageable pageable, // 게시글 식별자 값 + NoticeCategory noticeCategory // 게시글 유형 + ) {} + + @Builder + public record FindNoticeListByKeyword( + Pageable pageable, // 게시글 식별자 값 + String keyword, // 검색 키워드 + NoticeCategory noticeCategory // 게시글 유형 + ) {} + + @Builder + public record FindNoticeDetail( + Long noticeId, + Member member + ) {} + + public record CreateNotice( + @NotBlank(message = "제목을 입력해주세요") + String title, + @NotBlank(message = "내용을 입력해주세요") + String content, + @NotNull(message = "상단 고정 여부를 결정해주세요") + Boolean isPinned + ) {} + + public record UpdateNotice ( + @NotBlank(message = "제목을 입력해주세요") + String title, + @NotBlank(message = "내용을 입력해주세요") + String content, + @NotNull(message = "상단 고정 여부를 결정해주세요") + Boolean isPinned + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java new file mode 100644 index 0000000..e4fc557 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/dto/response/NoticeResponseDTO.java @@ -0,0 +1,36 @@ +package org.withtime.be.withtimebe.domain.notice.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; + +public record NoticeResponseDTO() { + + @Builder + public record NoticeList( + List noticeList, + Integer totalPages, // 전체 페이지 개수 + Integer currentPage, // 현재 페이지 번호 + Integer currentSize, // 현재 페이지의 크기 + Boolean hasNextPage // 다음 페이지 존재 여부 + ) {} + + @Builder + public record Notice( + Long noticeId, // 게시글 식별자 값 + String title, // 게시글 제목 + Boolean isPinned, // 고정 여부 + LocalDateTime createdAt // 생성 날짜 + ) {} + + @Builder + public record NoticeDetail( + Long noticeId, // 게시글 식별자 값 + String title, // 게시글 제목 + String content, // 게시글 내용 + Boolean isPinned, // 고정 여부 + Boolean hasAdminAuth, // 어드민 여부 + LocalDateTime createdAt // 생성 날짜 + ) {} +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java index 9ede00b..e343aa7 100644 --- a/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/entity/Notice.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import lombok.*; import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; import org.withtime.be.withtimebe.global.common.BaseEntity; @@ -27,6 +28,7 @@ public class Notice extends BaseEntity { @Column(name = "content") private String content; + @Enumerated(EnumType.STRING) @Column(name = "notice_category") private NoticeCategory noticeCategory; @@ -39,4 +41,14 @@ public class Notice extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; + + public void updateFields(NoticeRequestDTO.UpdateNotice updateNotice) { + this.title = updateNotice.title(); + this.content = updateNotice.content(); + this.isPinned = updateNotice.isPinned(); + } + + public void updateDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } } diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/repository/NoticeRepository.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..8194d07 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,54 @@ +package org.withtime.be.withtimebe.domain.notice.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +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 org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.entity.enums.NoticeCategory; + +public interface NoticeRepository extends JpaRepository { + + @Query(""" + SELECT n FROM Notice n + WHERE n.noticeCategory = :noticeCategory + AND n.deletedAt IS NULL + ORDER BY n.isPinned DESC, n.createdAt DESC + """) + Page findNoticeListByNoticeCategory( + @Param("noticeCategory") NoticeCategory noticeCategory, + Pageable pageable + ); + + @Query(""" + SELECT n FROM Notice n + WHERE n.noticeCategory = :noticeCategory + AND ( + n.title LIKE %:keyword% + OR n.content LIKE %:keyword% + ) + AND n.deletedAt IS NULL + ORDER BY n.isPinned DESC, n.createdAt DESC + """) + Page findNoticeListByNoticeCategoryAndKeyword( + @Param("noticeCategory") NoticeCategory noticeCategory, + @Param("keyword") String keyword, + Pageable pageable + ); + + @Query(""" + SELECT n FROM Notice n + WHERE n.noticeCategory = :noticeCategory + AND n.deletedAt IS NOT NULL + ORDER BY n.isPinned DESC, n.deletedAt ASC + """) + Page findTrashNoticeListByNoticeCategory( + @Param("noticeCategory") NoticeCategory noticeCategory, + Pageable pageable + ); + + Optional findNoticeById(Long noticeId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandService.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandService.java new file mode 100644 index 0000000..bd68520 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandService.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.notice.service.command; + +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; + +public interface NoticeCommandService { + Notice createNotice(NoticeRequestDTO.CreateNotice request, Member member); + Notice updateNotice(NoticeRequestDTO.UpdateNotice request, Long noticeId); + void softDeleteNotice(Long noticeId); + Notice recoverDeletedNotice(Long noticeId); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandServiceImpl.java new file mode 100644 index 0000000..ccc28dd --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/command/NoticeCommandServiceImpl.java @@ -0,0 +1,57 @@ +package org.withtime.be.withtimebe.domain.notice.service.command; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.notice.converter.NoticeConverter; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.repository.NoticeRepository; +import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; +import org.withtime.be.withtimebe.global.error.exception.NoticeException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = false) +public class NoticeCommandServiceImpl implements NoticeCommandService{ + + private final NoticeRepository noticeRepository; + + @Override + public Notice createNotice(NoticeRequestDTO.CreateNotice request, Member member) { + Notice notice = NoticeConverter.toNoticeEntity(request, member); + return noticeRepository.save(notice); + } + + @Override + public Notice updateNotice(NoticeRequestDTO.UpdateNotice request, Long noticeId) { + Notice notice = noticeRepository.findNoticeById(noticeId) + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + + notice.updateFields(request); + + return notice; + } + + @Override + public void softDeleteNotice(Long noticeId) { + Notice notice = noticeRepository.findNoticeById(noticeId) + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + + notice.updateDeletedAt(LocalDateTime.now()); + } + + @Override + public Notice recoverDeletedNotice(Long noticeId) { + Notice notice = noticeRepository.findNoticeById(noticeId) + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + + notice.updateDeletedAt(null); + + return notice; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java new file mode 100644 index 0000000..bc6bd86 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryService.java @@ -0,0 +1,12 @@ +package org.withtime.be.withtimebe.domain.notice.service.query; + +import org.springframework.data.domain.Page; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; + +public interface NoticeQueryService { + Page findNoticeList(NoticeRequestDTO.FindNoticeList request); + Page findNoticeListByKeyword(NoticeRequestDTO.FindNoticeListByKeyword request); + Page findTrashNoticeList(NoticeRequestDTO.FindNoticeList request); + Notice findNoticeDetail(NoticeRequestDTO.FindNoticeDetail request); +} diff --git a/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java new file mode 100644 index 0000000..4846417 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/domain/notice/service/query/NoticeQueryServiceImpl.java @@ -0,0 +1,59 @@ +package org.withtime.be.withtimebe.domain.notice.service.query; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.withtime.be.withtimebe.domain.member.entity.Member; +import org.withtime.be.withtimebe.domain.member.entity.enums.Role; +import org.withtime.be.withtimebe.domain.notice.dto.request.NoticeRequestDTO; +import org.withtime.be.withtimebe.domain.notice.entity.Notice; +import org.withtime.be.withtimebe.domain.notice.repository.NoticeRepository; +import org.withtime.be.withtimebe.global.error.code.AuthErrorCode; +import org.withtime.be.withtimebe.global.error.code.NoticeErrorCode; +import org.withtime.be.withtimebe.global.error.exception.AuthException; +import org.withtime.be.withtimebe.global.error.exception.NoticeException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeQueryServiceImpl implements NoticeQueryService { + + private final NoticeRepository noticeRepository; + + @Override + public Page findNoticeList(NoticeRequestDTO.FindNoticeList request) { + return noticeRepository.findNoticeListByNoticeCategory( + request.noticeCategory(), request.pageable()); + } + + @Override + public Page findNoticeListByKeyword(NoticeRequestDTO.FindNoticeListByKeyword request) { + return noticeRepository.findNoticeListByNoticeCategoryAndKeyword( + request.noticeCategory(), request.keyword(), request.pageable()); + } + + @Override + public Page findTrashNoticeList(NoticeRequestDTO.FindNoticeList request) { + return noticeRepository.findTrashNoticeListByNoticeCategory( + request.noticeCategory(), request.pageable() + ); + } + + @Override + public Notice findNoticeDetail(NoticeRequestDTO.FindNoticeDetail request) { + + Notice notice = noticeRepository.findNoticeById(request.noticeId()) + .orElseThrow(() -> new NoticeException(NoticeErrorCode.NOTICE_NOT_FOUND)); + Member member = request.member(); + + // 삭제된 게시글을 USER가 보려는 경우 처리 + if(notice.getDeletedAt() != null) { + if(member == null || !member.getRole().equals(Role.ADMIN)) + throw new AuthException(NoticeErrorCode.DELETED_NOTICE_FORBIDDEN_ACCESS); + } + + return notice; + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java b/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java new file mode 100644 index 0000000..b14fb22 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/annotation/SwaggerPageable.java @@ -0,0 +1,35 @@ +package org.withtime.be.withtimebe.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; + +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Parameters({ + @Parameter( + name = "pageable", + hidden = true + ), + @Parameter( + in = ParameterIn.QUERY, + name = "page", + description = "페이지 번호입니다.", + schema = @io.swagger.v3.oas.annotations.media.Schema(type = "integer", defaultValue = "0"), + required = true + ), + @Parameter( + in = ParameterIn.QUERY, + name = "size", + description = "한 페이지에 표시될 데이터 개수 입니다.", + schema = @io.swagger.v3.oas.annotations.media.Schema(type = "integer", defaultValue = "10"), + required = true + ) +}) +public @interface SwaggerPageable { +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java b/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java new file mode 100644 index 0000000..6c6389f --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/code/NoticeErrorCode.java @@ -0,0 +1,31 @@ +package org.withtime.be.withtimebe.global.error.code; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.code.dto.ErrorReasonDTO; +import org.namul.api.payload.code.dto.supports.DefaultResponseErrorReasonDTO; +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum NoticeErrorCode implements BaseErrorCode { + + DELETED_NOTICE_FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "NOTICE403_1", "삭제된 공지사항을 열람할 Admin 권한이 없습니다."), + + NOTICE_CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE404_1", "해당하는 공지사항 유형을 찾을 수 없습니다."), + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE404_2", "해당하는 공지사항을 찾을 수 없습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return DefaultResponseErrorReasonDTO.builder() + .httpStatus(this.httpStatus) + .code(this.code) + .message(this.message) + .build(); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/error/exception/NoticeException.java b/src/main/java/org/withtime/be/withtimebe/global/error/exception/NoticeException.java new file mode 100644 index 0000000..8cbedf4 --- /dev/null +++ b/src/main/java/org/withtime/be/withtimebe/global/error/exception/NoticeException.java @@ -0,0 +1,10 @@ +package org.withtime.be.withtimebe.global.error.exception; + +import org.namul.api.payload.code.BaseErrorCode; +import org.namul.api.payload.error.exception.ServerApplicationException; + +public class NoticeException extends ServerApplicationException { + public NoticeException(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java index 1e71c67..bb86e4c 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/SecurityConfig.java @@ -39,6 +39,7 @@ public class SecurityConfig { private String[] allowUrl = { API_PREFIX + "/auth/**", + API_PREFIX + "/notices/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**" @@ -49,6 +50,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(request -> request .requestMatchers(allowUrl).permitAll() + .requestMatchers(API_PREFIX + "/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jsonLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/java/org/withtime/be/withtimebe/global/security/domain/CustomUserDetails.java b/src/main/java/org/withtime/be/withtimebe/global/security/domain/CustomUserDetails.java index eecc7d0..ed57c06 100644 --- a/src/main/java/org/withtime/be/withtimebe/global/security/domain/CustomUserDetails.java +++ b/src/main/java/org/withtime/be/withtimebe/global/security/domain/CustomUserDetails.java @@ -28,7 +28,7 @@ public String getPassword() { @Override public Collection getAuthorities() { - return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); + return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())); } public String getProviderType() {