diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java index 0e5853f..937228f 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionDTO.java @@ -1,6 +1,7 @@ package org.openpodcastapi.opa.subscription; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; import lombok.NonNull; import org.hibernate.validator.constraints.URL; @@ -23,17 +24,17 @@ public record SubscriptionCreateDTO( /// A DTO representing a user's subscription to a given feed /// - /// @param uuid the feed UUID - /// @param feedUrl the feed URL - /// @param createdAt the date at which the subscription link was created - /// @param updatedAt the date at which the subscription link was last updated - /// @param isSubscribed whether the user is currently subscribed to the feed + /// @param uuid the feed UUID + /// @param feedUrl the feed URL + /// @param createdAt the date at which the subscription link was created + /// @param updatedAt the date at which the subscription link was last updated + /// @param unsubscribedAt the date at which the user unsubscribed from the feed public record UserSubscriptionDTO( @JsonProperty(required = true) @UUID java.util.UUID uuid, @JsonProperty(required = true) @URL String feedUrl, @JsonProperty(required = true) Instant createdAt, @JsonProperty(required = true) Instant updatedAt, - @JsonProperty(required = true) Boolean isSubscribed + @JsonProperty @Nullable Instant unsubscribedAt ) { } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java index 9268771..001fca9 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/SubscriptionService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -73,7 +74,7 @@ public SubscriptionDTO.UserSubscriptionDTO getUserSubscriptionBySubscriptionUuid @Transactional(readOnly = true) public Page getAllActiveSubscriptionsForUser(Long userId, Pageable pageable) { log.debug("Fetching all active subscriptions for {}", userId); - return userSubscriptionRepository.findAllByUserIdAndIsSubscribedTrue(userId, pageable).map(userSubscriptionMapper::toDto); + return userSubscriptionRepository.findAllByUserIdAndUnsubscribedAtNotEmpty(userId, pageable).map(userSubscriptionMapper::toDto); } /// Persists a new user subscription to the database @@ -91,13 +92,13 @@ protected SubscriptionDTO.UserSubscriptionDTO persistUserSubscription(Subscripti final var newSubscription = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, subscriptionEntity.getUuid()).orElseGet(() -> { log.debug("Creating new subscription for user {} and subscription {}", userId, subscriptionEntity.getUuid()); final var createdSubscriptionEntity = new UserSubscriptionEntity(); - createdSubscriptionEntity.setIsSubscribed(true); + createdSubscriptionEntity.setUnsubscribedAt(null); createdSubscriptionEntity.setUser(userEntity); createdSubscriptionEntity.setSubscription(subscriptionEntity); return userSubscriptionRepository.save(createdSubscriptionEntity); }); - newSubscription.setIsSubscribed(true); + newSubscription.setUnsubscribedAt(null); return userSubscriptionMapper.toDto(userSubscriptionRepository.save(newSubscription)); } @@ -143,7 +144,7 @@ public SubscriptionDTO.UserSubscriptionDTO unsubscribeUserFromFeed(UUID feedUUID final var userSubscriptionEntity = userSubscriptionRepository.findByUserIdAndSubscriptionUuid(userId, feedUUID) .orElseThrow(() -> new EntityNotFoundException("no subscription found")); - userSubscriptionEntity.setIsSubscribed(false); + userSubscriptionEntity.setUnsubscribedAt(Instant.now()); return userSubscriptionMapper.toDto(userSubscriptionRepository.save(userSubscriptionEntity)); } } diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java index 97ee197..a99203a 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionEntity.java @@ -31,15 +31,15 @@ public class UserSubscriptionEntity { @JoinColumn(name = "subscription_id") private SubscriptionEntity subscription; - @Column(columnDefinition = "boolean default true") - private Boolean isSubscribed; - @Column(nullable = false, updatable = false) private Instant createdAt; @Column(nullable = false) private Instant updatedAt; + @Column + private Instant unsubscribedAt; + @PrePersist public void prePersist() { this.setUuid(UUID.randomUUID()); diff --git a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java index 081eb76..33cb396 100644 --- a/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java +++ b/src/main/java/org/openpodcastapi/opa/subscription/UserSubscriptionRepository.java @@ -15,5 +15,5 @@ public interface UserSubscriptionRepository extends JpaRepository<@NonNull UserS Page<@NonNull UserSubscriptionEntity> findAllByUserId(Long userId, Pageable pageable); - Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndIsSubscribedTrue(Long userId, Pageable pageable); + Page<@NonNull UserSubscriptionEntity> findAllByUserIdAndUnsubscribedAtNotEmpty(Long userId, Pageable pageable); } diff --git a/src/main/resources/db/migration/V2__.sql b/src/main/resources/db/migration/V2__.sql new file mode 100644 index 0000000..953b317 --- /dev/null +++ b/src/main/resources/db/migration/V2__.sql @@ -0,0 +1,5 @@ +ALTER TABLE user_subscription + ADD unsubscribed_at TIMESTAMP WITHOUT TIME ZONE; + +ALTER TABLE user_subscription + DROP COLUMN is_subscribed; \ No newline at end of file diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java index a9da859..bf0326a 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/SubscriptionEntityRestControllerTest.java @@ -97,8 +97,8 @@ void getAllSubscriptionsForAnonymous_shouldReturn401() throws Exception { @Test @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { - SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); - SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), true); + SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null); + SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), null); Page page = new PageImpl<>(List.of(sub1, sub2)); when(subscriptionService.getAllActiveSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) @@ -124,11 +124,11 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { .description("If true, includes unsubscribed feeds in the results. Defaults to false.") ), responseFields( - fieldWithPath("subscriptions[].uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].createdAt").description("Creation timestamp of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].updatedAt").description("Last update timestamp of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("subscriptions[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), + fieldWithPath("subscriptions[].uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].createdAt").description("Creation timestamp of the subscription").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].updatedAt").description("Last update timestamp of the subscription").type(JsonFieldType.STRING), + fieldWithPath("subscriptions[].unsubscribedAt").description("The date at which the user unsubscribed from the feed").type(JsonFieldType.STRING).optional(), fieldWithPath("page").description("Current page number").type(JsonFieldType.NUMBER), fieldWithPath("size").description("Size of the page").type(JsonFieldType.NUMBER), fieldWithPath("totalElements").description("Total number of subscriptions").type(JsonFieldType.NUMBER), @@ -143,8 +143,8 @@ void getAllSubscriptionsForUser_shouldReturnSubscriptions() throws Exception { @Test @WithMockUser(username = "user") void getAllSubscriptionsForUser_shouldIncludeUnsubscribedWhenRequested() throws Exception { - SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), true); - SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), false); + SubscriptionDTO.UserSubscriptionDTO sub1 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed1", Instant.now(), Instant.now(), null); + SubscriptionDTO.UserSubscriptionDTO sub2 = new SubscriptionDTO.UserSubscriptionDTO(UUID.randomUUID(), "test.com/feed2", Instant.now(), Instant.now(), Instant.now()); Page page = new PageImpl<>(List.of(sub1, sub2)); when(subscriptionService.getAllSubscriptionsForUser(eq(mockUser.getId()), any(Pageable.class))) @@ -183,28 +183,28 @@ void getNonexistentSubscription_shouldReturnNotFound() throws Exception { void getSubscriptionByUuid_shouldReturnSubscription() throws Exception { UUID subscriptionUuid = UUID.randomUUID(); - SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), true); + SubscriptionDTO.UserSubscriptionDTO sub = new SubscriptionDTO.UserSubscriptionDTO(subscriptionUuid, "test.com/feed1", Instant.now(), Instant.now(), null); when(subscriptionService.getUserSubscriptionBySubscriptionUuid(subscriptionUuid, mockUser.getId())) .thenReturn(sub); mockMvc.perform(get("/api/v1/subscriptions/{uuid}", subscriptionUuid) .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) - .andDo(document("subscriptionEntity-get", + .andDo(document("subscription-get", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("The access token used to authenticate the user") ), pathParameters( - parameterWithName("uuid").description("UUID of the subscriptionEntity to retrieve") + parameterWithName("uuid").description("UUID of the subscription to retrieve") ), responseFields( - fieldWithPath("uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), + fieldWithPath("uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), + fieldWithPath("feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), fieldWithPath("createdAt").description("Creation timestamp").type(JsonFieldType.STRING), fieldWithPath("updatedAt").description("Last update timestamp").type(JsonFieldType.STRING), - fieldWithPath("isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN) + fieldWithPath("unsubscribedAt").description("The date at which the user unsubscribed from the feed").type(JsonFieldType.STRING).optional() ) )); } @@ -237,7 +237,7 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { SubscriptionDTO.SubscriptionCreateDTO dto2 = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( - List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), + List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)), List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) ); @@ -256,16 +256,16 @@ void createUserSubscriptions_shouldReturnMixedResponse() throws Exception { headerWithName("Authorization").description("The access token used to authenticate the user") ), requestFields( - fieldWithPath("[].uuid").description("The UUID of the subscriptionEntity"), - fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") + fieldWithPath("[].uuid").description("The UUID of the subscription"), + fieldWithPath("[].feedUrl").description("The feed URL of the subscription to create") ), responseFields( fieldWithPath("success[]").description("List of feed URLs successfully added").type(JsonFieldType.ARRAY), fieldWithPath("success[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), fieldWithPath("success[].feedUrl").description("The feed URL").type(JsonFieldType.STRING), - fieldWithPath("success[].createdAt").description("The timestamp at which the subscriptionEntity was created").type(JsonFieldType.STRING), - fieldWithPath("success[].updatedAt").description("The timestamp at which the subscriptionEntity was updated").type(JsonFieldType.STRING), - fieldWithPath("success[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), + fieldWithPath("success[].createdAt").description("The timestamp at which the subscription was created").type(JsonFieldType.STRING), + fieldWithPath("success[].updatedAt").description("The timestamp at which the subscription was updated").type(JsonFieldType.STRING), + fieldWithPath("success[].unsubscribedAt").description("The date at which the user unsubscribed from the feed").type(JsonFieldType.STRING).optional(), fieldWithPath("failure[]").description("List of feed URLs that failed to add").type(JsonFieldType.ARRAY), fieldWithPath("failure[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), fieldWithPath("failure[].feedUrl").description("The feed URL").type(JsonFieldType.STRING), @@ -282,8 +282,8 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(goodFeedUUID.toString(), "test.com/feed1"); - SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( - List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, true)), + final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO( + List.of(new SubscriptionDTO.UserSubscriptionDTO(goodFeedUUID, "test.com/feed1", timestamp, timestamp, null)), List.of() ); @@ -302,16 +302,16 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { headerWithName("Authorization").description("The access token used to authenticate the user") ), requestFields( - fieldWithPath("[].uuid").description("The UUID of the subscriptionEntity"), - fieldWithPath("[].feedUrl").description("The feed URL of the subscriptionEntity to create") + fieldWithPath("[].uuid").description("The UUID of the subscription"), + fieldWithPath("[].feedUrl").description("The feed URL of the subscription to create") ), responseFields( fieldWithPath("success[]").description("List of feed URLs successfully added").type(JsonFieldType.ARRAY), fieldWithPath("success[].uuid").description("The UUID of the feed").type(JsonFieldType.STRING), fieldWithPath("success[].feedUrl").description("The feed URL").type(JsonFieldType.STRING), - fieldWithPath("success[].createdAt").description("The timestamp at which the subscriptionEntity was created").type(JsonFieldType.STRING), - fieldWithPath("success[].updatedAt").description("The timestamp at which the subscriptionEntity was updated").type(JsonFieldType.STRING), - fieldWithPath("success[].isSubscribed").description("Whether the user is subscribed to the feed").type(JsonFieldType.BOOLEAN), + fieldWithPath("success[].createdAt").description("The timestamp at which the subscription was created").type(JsonFieldType.STRING), + fieldWithPath("success[].updatedAt").description("The timestamp at which the subscription was updated").type(JsonFieldType.STRING), + fieldWithPath("success[].unsubscribedAt").description("The date at which the user unsubscribed from the feed").type(JsonFieldType.STRING).optional(), fieldWithPath("failure[]").description("List of feed URLs that failed to add").type(JsonFieldType.ARRAY).ignored()))); } @@ -320,9 +320,9 @@ void createUserSubscription_shouldReturnSuccess() throws Exception { void createUserSubscription_shouldReturnFailure() throws Exception { final String BAD_UUID = "62ad30ce-aac0-4f0a-a811"; - SubscriptionDTO.SubscriptionCreateDTO dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); + final var dto = new SubscriptionDTO.SubscriptionCreateDTO(BAD_UUID, "test.com/feed2"); - SubscriptionDTO.BulkSubscriptionResponseDTO response = new SubscriptionDTO.BulkSubscriptionResponseDTO( + final var response = new SubscriptionDTO.BulkSubscriptionResponseDTO( List.of(), List.of(new SubscriptionDTO.SubscriptionFailureDTO(BAD_UUID, "test.com/feed2", "invalid UUID format")) ); @@ -372,15 +372,15 @@ void unsubscribingNonexistentEntity_shouldReturnNotFound() throws Exception { @Test @WithMockUser(username = "user") void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { - UUID subscriptionUuid = UUID.randomUUID(); - boolean newStatus = false; + final var subscriptionUuid = UUID.randomUUID(); + final var timestamp = Instant.now(); SubscriptionDTO.UserSubscriptionDTO updatedSubscription = new SubscriptionDTO.UserSubscriptionDTO( subscriptionUuid, "test.com/feed1", - Instant.now(), - Instant.now(), - newStatus + timestamp, + timestamp, + timestamp ); when(subscriptionService.unsubscribeUserFromFeed(subscriptionUuid, mockUser.getId())) @@ -393,22 +393,22 @@ void unsubscribe_shouldReturnUpdatedSubscription() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.uuid").value(subscriptionUuid.toString())) .andExpect(jsonPath("$.feedUrl").value("test.com/feed1")) - .andExpect(jsonPath("$.isSubscribed").value(false)) - .andDo(document("subscriptionEntity-unsubscribe", + .andExpect(jsonPath("$.unsubscribedAt").value(timestamp.toString())) + .andDo(document("subscription-unsubscribe", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), requestHeaders( headerWithName("Authorization").description("The access token used to authenticate the user") ), pathParameters( - parameterWithName("uuid").description("UUID of the subscriptionEntity to update") + parameterWithName("uuid").description("UUID of the subscription to update") ), responseFields( - fieldWithPath("uuid").description("The UUID of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("feedUrl").description("The feed URL of the subscriptionEntity").type(JsonFieldType.STRING), - fieldWithPath("createdAt").description("When the subscriptionEntity was created").type(JsonFieldType.STRING), - fieldWithPath("updatedAt").description("When the subscriptionEntity was last updated").type(JsonFieldType.STRING), - fieldWithPath("isSubscribed").description("The updated subscriptionEntity status").type(JsonFieldType.BOOLEAN) + fieldWithPath("uuid").description("The UUID of the subscription").type(JsonFieldType.STRING), + fieldWithPath("feedUrl").description("The feed URL of the subscription").type(JsonFieldType.STRING), + fieldWithPath("createdAt").description("When the subscription was created").type(JsonFieldType.STRING), + fieldWithPath("updatedAt").description("When the subscription was last updated").type(JsonFieldType.STRING), + fieldWithPath("unsubscribedAt").description("The date at which the user unsubscribed from the feed").type(JsonFieldType.STRING) ) )); } diff --git a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java index ad69b27..5ef50ef 100644 --- a/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java +++ b/src/test/java/org/openpodcastapi/opa/subscriptions/UserSubscriptionEntityMapperTest.java @@ -47,7 +47,6 @@ void testToDto() { .uuid(uuid) .user(userEntity) .subscription(subscriptionEntity) - .isSubscribed(true) .createdAt(timestamp) .updatedAt(timestamp) .build(); @@ -60,6 +59,6 @@ void testToDto() { // The DTO should use the SubscriptionEntity's UUID rather than the UserSubscriptionEntity's assertEquals(subscriptionEntity.getUuid(), dto.uuid()); - assertTrue(dto.isSubscribed()); + assertNull(dto.unsubscribedAt()); } }