diff --git a/docs/contracts/rest-api-v1.md b/docs/contracts/rest-api-v1.md index 9299d0a..9586f3b 100644 --- a/docs/contracts/rest-api-v1.md +++ b/docs/contracts/rest-api-v1.md @@ -91,6 +91,11 @@ This document defines the MVP API contract groups, standards, and core request/r - Returns `409 CONTENT_NOT_READY` when playback is not ready - Returns `409 CONTENT_STATE_INVALID` when state is not `DRAFT` +### `POST /v1/content/{content_id}/unpublish` +- Unpublishes content from `PUBLISHED` to `PRIVATE` +- Request fields (MVP): `userId` +- Returns `409 CONTENT_STATE_INVALID` when state is not `PUBLISHED` + ### `GET /v1/content/{content_id}/playback` - Returns signed manifest URL and available renditions - Applies age/geo/moderation checks diff --git a/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java b/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java index f8120e5..8b04e7c 100644 --- a/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java +++ b/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java @@ -59,6 +59,14 @@ public ResponseEntity> publishContent( return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId))); } + @PostMapping("/content/{contentId}/unpublish") + public ResponseEntity> unpublishContent( + @PathVariable("contentId") @NotBlank String contentId, @Valid @RequestBody PublishContentRequest request, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + ContentResponse response = contentService.unpublish(contentId, request.userId()); + return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId))); + } + @GetMapping("/content/{contentId}/playback") public ResponseEntity> getPlayback( @PathVariable("contentId") @NotBlank String contentId, diff --git a/services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java b/services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java index bf8ff1a..b7191dd 100644 --- a/services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java +++ b/services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java @@ -103,6 +103,23 @@ public ContentResponse publish(String contentId, String userId) { return toResponse(contentRepository.save(content)); } + @Transactional + public ContentResponse unpublish(String contentId, String userId) { + ContentEntity content = contentRepository.findById(contentId).orElseThrow( + () -> new ApiException(HttpStatus.NOT_FOUND, "CONTENT_NOT_FOUND", "Content not found", null)); + assertMember(content.getChannel().getId(), userId); + + if (content.getState() != ContentState.PUBLISHED) { + throw new ApiException(HttpStatus.CONFLICT, "CONTENT_STATE_INVALID", + "Content can only be unpublished from published state", null); + } + + content.setState(ContentState.PRIVATE); + content.setUpdatedAt(LocalDateTime.now()); + + return toResponse(contentRepository.save(content)); + } + @Transactional(readOnly = true) public PlaybackResponse getPlayback(String contentId, String countryCode, Boolean ageVerified) { ContentEntity content = contentRepository.findById(contentId).orElseThrow( diff --git a/services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java b/services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java index 66328e3..b57b745 100644 --- a/services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java +++ b/services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java @@ -230,6 +230,54 @@ void publishReturnsForbiddenForNonMember() throws Exception { .andExpect(jsonPath("$.error.code").value("CHANNEL_ACCESS_DENIED")); } + @Test + void unpublishReturnsPrivateContent() throws Exception { + ChannelEntity channel = saveChannel("channel-11", "lambda-channel"); + saveMembership(channel, "user-11", ChannelMemberRole.ADMIN); + ContentEntity content = saveContent(channel, "Published", "Ready", ContentVisibility.PUBLIC, + ContentState.PUBLISHED, true); + + mockMvc.perform(post("/v1/content/{contentId}/unpublish", content.getId()) + .contentType(MediaType.APPLICATION_JSON).header("X-Request-Id", "req_content_unpublish_1").content(""" + { + "userId": "user-11" + } + """)).andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(content.getId())) + .andExpect(jsonPath("$.data.state").value("PRIVATE")) + .andExpect(jsonPath("$.meta.requestId").value("req_content_unpublish_1")); + } + + @Test + void unpublishReturnsConflictWhenStateIsNotPublished() throws Exception { + ChannelEntity channel = saveChannel("channel-12", "mu-channel"); + saveMembership(channel, "user-12", ChannelMemberRole.OWNER); + ContentEntity content = saveContent(channel, "Draft", "Not published", ContentVisibility.PRIVATE, + ContentState.DRAFT, true); + + mockMvc.perform(post("/v1/content/{contentId}/unpublish", content.getId()) + .contentType(MediaType.APPLICATION_JSON).content(""" + { + "userId": "user-12" + } + """)).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CONTENT_STATE_INVALID")); + } + + @Test + void unpublishReturnsForbiddenForNonMember() throws Exception { + ChannelEntity channel = saveChannel("channel-13", "nu-channel"); + ContentEntity content = saveContent(channel, "Published", "Ready", ContentVisibility.PUBLIC, + ContentState.PUBLISHED, true); + + mockMvc.perform(post("/v1/content/{contentId}/unpublish", content.getId()) + .contentType(MediaType.APPLICATION_JSON).content(""" + { + "userId": "user-13" + } + """)).andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CHANNEL_ACCESS_DENIED")); + } + private ChannelEntity saveChannel(String id, String slug) { ChannelEntity channel = new ChannelEntity(); channel.setId(id); diff --git a/services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java b/services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java index 0413312..972120a 100644 --- a/services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java +++ b/services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java @@ -171,6 +171,33 @@ void publishRejectsWhenStateIsNotDraft() { assertEquals("CONTENT_STATE_INVALID", exception.getCode()); } + @Test + void unpublishTransitionsPublishedToPrivate() { + ChannelEntity channel = saveChannel("channel-content-9", "content-channel-nine"); + saveMembership(channel, "publisher-4", ChannelMemberRole.OWNER); + ContentEntity content = saveContent(channel, "Published", "desc", ContentVisibility.PUBLIC, + ContentState.PUBLISHED, true); + LocalDateTime publishedAtBefore = content.getPublishedAt(); + + var unpublished = contentService.unpublish(content.getId(), "publisher-4"); + + assertEquals(ContentState.PRIVATE, unpublished.state()); + assertEquals(publishedAtBefore, unpublished.publishedAt()); + } + + @Test + void unpublishRejectsWhenStateIsNotPublished() { + ChannelEntity channel = saveChannel("channel-content-10", "content-channel-ten"); + saveMembership(channel, "publisher-5", ChannelMemberRole.ADMIN); + ContentEntity content = saveContent(channel, "Draft", "desc", ContentVisibility.PRIVATE, ContentState.DRAFT, + true); + + ApiException exception = assertThrows(ApiException.class, + () -> contentService.unpublish(content.getId(), "publisher-5")); + + assertEquals("CONTENT_STATE_INVALID", exception.getCode()); + } + private ChannelEntity saveChannel(String id, String slug) { ChannelEntity channel = new ChannelEntity(); channel.setId(id);