diff --git a/src/main/java/com/onebyte/life4cut/album/controller/AlbumController.java b/src/main/java/com/onebyte/life4cut/album/controller/AlbumController.java index 057b516..f4f1c46 100644 --- a/src/main/java/com/onebyte/life4cut/album/controller/AlbumController.java +++ b/src/main/java/com/onebyte/life4cut/album/controller/AlbumController.java @@ -1,11 +1,14 @@ package com.onebyte.life4cut.album.controller; +import com.onebyte.life4cut.album.controller.dto.CreateAlbumRequest; +import com.onebyte.life4cut.album.controller.dto.CreateAlbumResponse; import com.onebyte.life4cut.album.controller.dto.CreatePictureRequest; import com.onebyte.life4cut.album.controller.dto.CreatePictureResponse; import com.onebyte.life4cut.album.controller.dto.GetMyRoleInAlbumResponse; import com.onebyte.life4cut.album.controller.dto.GetPicturesInSlotResponse; import com.onebyte.life4cut.album.controller.dto.SearchTagsRequest; import com.onebyte.life4cut.album.controller.dto.SearchTagsResponse; +import com.onebyte.life4cut.album.controller.dto.UpdateAlbumRequest; import com.onebyte.life4cut.album.controller.dto.UpdatePictureRequest; import com.onebyte.life4cut.album.domain.vo.UserAlbumRole; import com.onebyte.life4cut.album.service.AlbumService; @@ -21,10 +24,12 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; @@ -39,6 +44,41 @@ public class AlbumController { private final PictureTagService pictureTagService; private final AlbumService albumService; + @PostMapping("") + public ApiResponse createAlbum( + @Valid @RequestBody CreateAlbumRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long albumId = + albumService.createAlbum( + request.name(), + userDetails.getUserId(), + request.memberUserIds(), + request.guestUserIds()); + return ApiResponse.OK(new CreateAlbumResponse(albumId)); + } + + @DeleteMapping("/{albumId}") + public ApiResponse deleteAlbum( + @Min(1) @PathVariable("albumId") Long albumId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + albumService.deleteAlbum(albumId, userDetails.getUserId()); + return ApiResponse.OK(); + } + + @PatchMapping("/{albumId}") + public ApiResponse updateAlbum( + @Min(1) @PathVariable("albumId") Long albumId, + @Valid @RequestBody UpdateAlbumRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + albumService.updateAlbum( + albumId, + request.name(), + userDetails.getUserId(), + request.memberUserIds(), + request.guestUserIds()); + return ApiResponse.OK(); + } + @PostMapping("/{albumId}/pictures") public ApiResponse uploadPicture( @Min(1) @PathVariable("albumId") Long albumId, diff --git a/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumRequest.java b/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumRequest.java new file mode 100644 index 0000000..85f6682 --- /dev/null +++ b/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumRequest.java @@ -0,0 +1,10 @@ +package com.onebyte.life4cut.album.controller.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +public record CreateAlbumRequest( + @NotNull(message = "앨범의 이름을 입력해주세요") String name, + List<@Min(value = 1, message = "올바른 memberUserIds를 입력해주세요") Long> memberUserIds, + List<@Min(value = 1, message = "올바른 guestUserIds를 입력해주세요") Long> guestUserIds) {} diff --git a/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumResponse.java b/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumResponse.java new file mode 100644 index 0000000..f01268d --- /dev/null +++ b/src/main/java/com/onebyte/life4cut/album/controller/dto/CreateAlbumResponse.java @@ -0,0 +1,3 @@ +package com.onebyte.life4cut.album.controller.dto; + +public record CreateAlbumResponse(Long id) {} diff --git a/src/main/java/com/onebyte/life4cut/album/controller/dto/UpdateAlbumRequest.java b/src/main/java/com/onebyte/life4cut/album/controller/dto/UpdateAlbumRequest.java new file mode 100644 index 0000000..f7c32be --- /dev/null +++ b/src/main/java/com/onebyte/life4cut/album/controller/dto/UpdateAlbumRequest.java @@ -0,0 +1,10 @@ +package com.onebyte.life4cut.album.controller.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.util.List; + +public record UpdateAlbumRequest( + @NotBlank(message = "앨범의 이름을 입력해주세요") String name, + List<@Min(value = 1, message = "올바른 memberUserIds를 입력해주세요") Long> memberUserIds, + List<@Min(value = 1, message = "올바른 guestUserIds를 입력해주세요") Long> guestUserIds) {} diff --git a/src/main/java/com/onebyte/life4cut/album/domain/Album.java b/src/main/java/com/onebyte/life4cut/album/domain/Album.java index fea3b28..7ce7cd0 100644 --- a/src/main/java/com/onebyte/life4cut/album/domain/Album.java +++ b/src/main/java/com/onebyte/life4cut/album/domain/Album.java @@ -6,13 +6,25 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import java.time.LocalDateTime; +import lombok.Setter; @Entity public class Album extends BaseEntity { + @Setter @Nonnull @Column(nullable = false) private String name; @Nullable @Column private LocalDateTime deletedAt; + + public static Album create(@Nonnull String name) { + Album album = new Album(); + album.name = name; + return album; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/onebyte/life4cut/album/domain/UserAlbum.java b/src/main/java/com/onebyte/life4cut/album/domain/UserAlbum.java index 70f3775..3305c3e 100644 --- a/src/main/java/com/onebyte/life4cut/album/domain/UserAlbum.java +++ b/src/main/java/com/onebyte/life4cut/album/domain/UserAlbum.java @@ -36,4 +36,33 @@ public class UserAlbum extends BaseEntity { public boolean isGuest() { return role == UserAlbumRole.GUEST; } + + public boolean isHost() { + return role == UserAlbumRole.HOST; + } + + public static UserAlbum createHost(@Nonnull Long albumId, @Nonnull Long userId) { + return createUserAlbum(albumId, userId, UserAlbumRole.HOST); + } + + public static UserAlbum createMember(@Nonnull Long albumId, @Nonnull Long userId) { + return createUserAlbum(albumId, userId, UserAlbumRole.MEMBER); + } + + public static UserAlbum createGuest(@Nonnull Long albumId, @Nonnull Long userId) { + return createUserAlbum(albumId, userId, UserAlbumRole.GUEST); + } + + private static UserAlbum createUserAlbum( + @Nonnull Long albumId, @Nonnull Long userId, @Nonnull UserAlbumRole role) { + UserAlbum userAlbum = new UserAlbum(); + userAlbum.albumId = albumId; + userAlbum.userId = userId; + userAlbum.role = role; + return userAlbum; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepository.java b/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepository.java index 9f0fb73..4db932f 100644 --- a/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepository.java +++ b/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepository.java @@ -4,6 +4,9 @@ import java.util.Optional; public interface AlbumRepository { - Optional findById(Long id); + + Album save(Album album); + + void deleteById(Long id); } diff --git a/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepositoryImpl.java b/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepositoryImpl.java index 0e74d42b..6e0f0f4 100644 --- a/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepositoryImpl.java +++ b/src/main/java/com/onebyte/life4cut/album/repository/AlbumRepositoryImpl.java @@ -4,6 +4,9 @@ import com.onebyte.life4cut.album.domain.Album; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.transaction.Transactional; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -11,6 +14,7 @@ public class AlbumRepositoryImpl implements AlbumRepository { private final JPAQueryFactory jpaQueryFactory; + @PersistenceContext private EntityManager entityManager; public AlbumRepositoryImpl(JPAQueryFactory jpaQueryFactory) { this.jpaQueryFactory = jpaQueryFactory; @@ -23,4 +27,20 @@ public Optional findById(Long id) { .where(album.id.eq(id), album.deletedAt.isNull()) .fetchOne()); } + + @Transactional + public Album save(Album album) { + entityManager.persist(album); + return album; + } + + @Override + public void deleteById(Long id) { + Album album = entityManager.find(Album.class, id); + + if (album != null) { + album.softDelete(); + entityManager.merge(album); + } + } } diff --git a/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepository.java b/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepository.java index 1cc800b..bb91808 100644 --- a/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepository.java +++ b/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepository.java @@ -5,4 +5,10 @@ public interface UserAlbumRepository { Optional findByUserIdAndAlbumId(Long userId, Long albumId); + + UserAlbum save(UserAlbum userAlbum); + + void delete(UserAlbum userAlbum); + + void deleteByAlbumId(Long albumId); } diff --git a/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepositoryImpl.java b/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepositoryImpl.java index 3efabe8..a1c57c2 100644 --- a/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepositoryImpl.java +++ b/src/main/java/com/onebyte/life4cut/album/repository/UserAlbumRepositoryImpl.java @@ -4,6 +4,10 @@ import com.onebyte.life4cut.album.domain.UserAlbum; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -11,6 +15,7 @@ public class UserAlbumRepositoryImpl implements UserAlbumRepository { private final JPAQueryFactory jpaQueryFactory; + @PersistenceContext private EntityManager entityManager; public UserAlbumRepositoryImpl(JPAQueryFactory jpaQueryFactory) { this.jpaQueryFactory = jpaQueryFactory; @@ -26,4 +31,27 @@ public Optional findByUserIdAndAlbumId(Long userId, Long albumId) { userAlbum.deletedAt.isNull()) .fetchOne()); } + + @Override + public UserAlbum save(UserAlbum userAlbum) { + entityManager.persist(userAlbum); + return userAlbum; + } + + @Transactional + @Override + public void delete(UserAlbum userAlbum) { + userAlbum.softDelete(); + entityManager.merge(userAlbum); + } + + @Transactional + @Override + public void deleteByAlbumId(Long albumId) { + jpaQueryFactory + .update(userAlbum) + .set(userAlbum.deletedAt, LocalDateTime.now()) + .where(userAlbum.albumId.eq(albumId)) + .execute(); + } } diff --git a/src/main/java/com/onebyte/life4cut/album/service/AlbumService.java b/src/main/java/com/onebyte/life4cut/album/service/AlbumService.java index 5e045df..a47f6c9 100644 --- a/src/main/java/com/onebyte/life4cut/album/service/AlbumService.java +++ b/src/main/java/com/onebyte/life4cut/album/service/AlbumService.java @@ -1,11 +1,16 @@ package com.onebyte.life4cut.album.service; +import com.onebyte.life4cut.album.domain.Album; import com.onebyte.life4cut.album.domain.UserAlbum; import com.onebyte.life4cut.album.domain.vo.UserAlbumRole; import com.onebyte.life4cut.album.exception.AlbumNotFoundException; import com.onebyte.life4cut.album.exception.UserAlbumRolePermissionException; import com.onebyte.life4cut.album.repository.AlbumRepository; import com.onebyte.life4cut.album.repository.UserAlbumRepository; +import jakarta.annotation.Nonnull; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -28,4 +33,97 @@ public UserAlbumRole getRoleInAlbum(@NonNull Long albumId, @NonNull Long userId) return userAlbum.getRole(); } + + public Long createAlbum( + @Nonnull String name, + @Nonnull Long userId, + List memberUserIds, + List guestUserIds) { + Set userIds = new HashSet<>(); + + Album album = Album.create(name); + albumRepository.save(album); + Long albumId = album.getId(); + UserAlbum hostUserAlbum = UserAlbum.createHost(albumId, userId); + + userAlbumRepository.save(hostUserAlbum); + userIds.add(userId); + + if (memberUserIds != null) { + for (Long memberId : memberUserIds) { + if (userIds.add(memberId)) { + UserAlbum memberUserAlbum = UserAlbum.createMember(albumId, memberId); + userAlbumRepository.save(memberUserAlbum); + } + } + } + if (guestUserIds != null) { + for (Long guestId : guestUserIds) { + if (userIds.add(guestId)) { + UserAlbum guestUserAlbum = UserAlbum.createGuest(albumId, guestId); + userAlbumRepository.save(guestUserAlbum); + } + } + } + + return albumId; + } + + public void deleteAlbum(Long albumId, @NonNull Long userId) { + albumRepository.findById(albumId).orElseThrow(AlbumNotFoundException::new); + + UserAlbum userAlbum = + userAlbumRepository + .findByUserIdAndAlbumId(userId, albumId) + .orElseThrow(UserAlbumRolePermissionException::new); + + if (userAlbum.isHost()) { + albumRepository.deleteById(albumId); + userAlbumRepository.deleteByAlbumId(albumId); + } else { + userAlbumRepository.delete(userAlbum); + } + } + + public void updateAlbum( + Long albumId, String name, Long userId, List memberUserIds, List guestUserIds) { + Album album = albumRepository.findById(albumId).orElseThrow(() -> new AlbumNotFoundException()); + UserAlbum userAlbum = + userAlbumRepository + .findByUserIdAndAlbumId(userId, albumId) + .orElseThrow(UserAlbumRolePermissionException::new); + + if (!userAlbum.isHost()) { + throw new UserAlbumRolePermissionException(); + } + userAlbumRepository.deleteByAlbumId(albumId); + if (name != null) { + album.setName(name); + } + albumRepository.save(album); + + Set userIds = new HashSet<>(); + + UserAlbum hostUserAlbum = UserAlbum.createHost(albumId, userId); + + userAlbumRepository.save(hostUserAlbum); + userIds.add(userId); + + if (memberUserIds != null) { + for (Long memberId : memberUserIds) { + if (userIds.add(memberId)) { + UserAlbum memberUserAlbum = UserAlbum.createMember(albumId, memberId); + userAlbumRepository.save(memberUserAlbum); + } + } + } + if (guestUserIds != null) { + for (Long guestId : guestUserIds) { + if (userIds.add(guestId)) { + UserAlbum guestUserAlbum = UserAlbum.createGuest(albumId, guestId); + userAlbumRepository.save(guestUserAlbum); + } + } + } + } } diff --git a/src/test/java/com/onebyte/life4cut/album/controller/AlbumControllerTest.java b/src/test/java/com/onebyte/life4cut/album/controller/AlbumControllerTest.java index c8980f6..8f1e978 100644 --- a/src/test/java/com/onebyte/life4cut/album/controller/AlbumControllerTest.java +++ b/src/test/java/com/onebyte/life4cut/album/controller/AlbumControllerTest.java @@ -15,6 +15,7 @@ import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; import static org.springframework.restdocs.payload.JsonFieldType.STRING; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartBody; import static org.springframework.restdocs.payload.PayloadDocumentation.requestPartFields; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -23,7 +24,9 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.epages.restdocs.apispec.Schema; import com.epages.restdocs.apispec.SimpleType; +import com.onebyte.life4cut.album.controller.dto.CreateAlbumRequest; import com.onebyte.life4cut.album.controller.dto.CreatePictureRequest; +import com.onebyte.life4cut.album.controller.dto.UpdateAlbumRequest; import com.onebyte.life4cut.album.controller.dto.UpdatePictureRequest; import com.onebyte.life4cut.album.domain.vo.UserAlbumRole; import com.onebyte.life4cut.album.service.AlbumService; @@ -50,6 +53,7 @@ import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.generate.RestDocumentationGenerator; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; import org.springframework.restdocs.payload.JsonFieldType; import org.springframework.restdocs.snippet.Attributes; import org.springframework.test.web.servlet.ResultActions; @@ -397,4 +401,150 @@ void getMyRoleInAlbum() throws Exception { .build()))); } } + + @Nested + @WithCustomMockUser + class CreateAlbum { + + @Test + @DisplayName("앨범을 생성한다") + void createAlbum() throws Exception { + // given + String albumName = "앨범이름"; + List memberUserIds = List.of(1L, 2L); + List guestUserIds = List.of(3L, 4L); + + CreateAlbumRequest request = new CreateAlbumRequest(albumName, memberUserIds, guestUserIds); + + when(albumService.createAlbum(any(), any(), any(), any())).thenReturn(123L); + + // when + ResultActions result = + mockMvc.perform( + RestDocumentationRequestBuilders.post("/api/v1/albums") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.id").value(123)) + .andDo( + document( + "{class_name}/{method_name}", + resource( + ResourceSnippetParameters.builder() + .tag(API_TAG) + .description("앨범을 생성한다") + .summary("앨범을 생성한다") + .responseFields( + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data.id").type(NUMBER).description("앨범 아이디")) + .requestSchema(Schema.schema("CreateAlbumRequest")) + .responseSchema(Schema.schema("CreateAlbumResponse")) + .build()))); + } + } + + @Nested + @WithCustomMockUser + class DeleteAlbum { + + @Test + @DisplayName("앨범을 삭제한다") + void deleteAlbum() throws Exception { + // given + Long albumId = 1L; + + doNothing().when(albumService).deleteAlbum(any(), any()); + + // when + ResultActions result = + mockMvc.perform( + RestDocumentationRequestBuilders.delete("/api/v1/albums/{albumId}", albumId)); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andDo( + document( + "{class_name}/{method_name}", + resource( + ResourceSnippetParameters.builder() + .tag(API_TAG) + .description("앨범을 삭제한다") + .summary("앨범을 삭제한다") + .pathParameters( + parameterWithName("albumId") + .description("앨범 아이디") + .type(SimpleType.NUMBER)) + .responseFields( + fieldWithPath("message").type(STRING).description("응답 메시지"), + fieldWithPath("data").optional().description("빈 객체")) + .requestSchema(Schema.schema("DeleteAlbumRequest")) + .responseSchema(Schema.schema("DeleteAlbumResponse")) + .build()))); + } + } + + @Nested + @WithCustomMockUser + class UpdateAlbumTest { + + @Test + @DisplayName("앨범을 업데이트한다") + void updateAlbum() throws Exception { + // Given + Long albumId = 1L; + String albumName = "새로운앨범이름"; + List memberUserIds = List.of(5L, 6L); + List guestUserIds = List.of(7L, 8L); + + UpdateAlbumRequest request = new UpdateAlbumRequest(albumName, memberUserIds, guestUserIds); + + doNothing().when(albumService).updateAlbum(any(), any(), any(), any(), any()); + + // When + ResultActions result = + mockMvc.perform( + RestDocumentationRequestBuilders.patch("/api/v1/albums/{albumId}", albumId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // Then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andDo( + document( + "{class_name}/{method_name}", + resource( + ResourceSnippetParameters.builder() + .tag(API_TAG) + .description("앨범을 업데이트한다") + .summary("앨범을 업데이트한다") + .pathParameters( + parameterWithName("albumId") + .description("앨범 아이디") + .type(SimpleType.NUMBER)) + .requestSchema(Schema.schema("UpdateAlbumRequest")) + .responseSchema(Schema.schema("EmptyResponse")) + .build()), + requestFields( + fieldWithPath("name").type(STRING).description("앨범 이름").optional(), + fieldWithPath("memberUserIds[]") + .type(JsonFieldType.ARRAY) + .description("멤버 사용자 아이디 목록") + .attributes(Attributes.key("itemType").value(JsonFieldType.NUMBER)) + .optional(), + fieldWithPath("guestUserIds[]") + .type(JsonFieldType.ARRAY) + .description("게스트 사용자 아이디 목록") + .attributes(Attributes.key("itemType").value(JsonFieldType.NUMBER)) + .optional()))); + } + } + // }