diff --git a/docs/contracts/rest-api-v1.md b/docs/contracts/rest-api-v1.md index 797421c..9299d0a 100644 --- a/docs/contracts/rest-api-v1.md +++ b/docs/contracts/rest-api-v1.md @@ -85,8 +85,11 @@ This document defines the MVP API contract groups, standards, and core request/r - Mutable fields (MVP): `title`, `description`, `visibility` ### `POST /v1/content/{content_id}/publish` -- Idempotent publish request -- Enforces moderation/policy preconditions +- Publishes content from `DRAFT` to `PUBLISHED` +- Request fields (MVP): `userId` +- Requires `playbackReady=true` +- Returns `409 CONTENT_NOT_READY` when playback is not ready +- Returns `409 CONTENT_STATE_INVALID` when state is not `DRAFT` ### `GET /v1/content/{content_id}/playback` - Returns signed manifest URL and available renditions 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 234d7ec..f8120e5 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 @@ -3,6 +3,7 @@ import com.cloudmedia.content.api.content.dto.ContentResponse; import com.cloudmedia.content.api.content.dto.CreateContentRequest; import com.cloudmedia.content.api.content.dto.PlaybackResponse; +import com.cloudmedia.content.api.content.dto.PublishContentRequest; import com.cloudmedia.content.api.content.dto.UpdateContentRequest; import com.cloudmedia.content.api.response.ApiMeta; import com.cloudmedia.content.api.response.ApiSuccessResponse; @@ -50,6 +51,14 @@ public ResponseEntity> updateContentMetadata return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId))); } + @PostMapping("/content/{contentId}/publish") + public ResponseEntity> publishContent( + @PathVariable("contentId") @NotBlank String contentId, @Valid @RequestBody PublishContentRequest request, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + ContentResponse response = contentService.publish(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/api/content/dto/PublishContentRequest.java b/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/dto/PublishContentRequest.java new file mode 100644 index 0000000..d1fb029 --- /dev/null +++ b/services/java/content-service/src/main/java/com/cloudmedia/content/api/content/dto/PublishContentRequest.java @@ -0,0 +1,7 @@ +package com.cloudmedia.content.api.content.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PublishContentRequest(@NotBlank @Size(max = 36) String userId) { +} 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 2ae89f8..bf8ff1a 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 @@ -81,6 +81,28 @@ public ContentResponse updateMetadata(String contentId, UpdateContentRequest req return toResponse(contentRepository.save(content)); } + @Transactional + public ContentResponse publish(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.DRAFT) { + throw new ApiException(HttpStatus.CONFLICT, "CONTENT_STATE_INVALID", + "Content can only be published from draft state", null); + } + if (!content.isPlaybackReady()) { + throw new ApiException(HttpStatus.CONFLICT, "CONTENT_NOT_READY", "Content is not ready for publish", null); + } + + LocalDateTime now = LocalDateTime.now(); + content.setState(ContentState.PUBLISHED); + content.setPublishedAt(now); + content.setUpdatedAt(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 c6e7059..66328e3 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 @@ -181,6 +181,55 @@ void getPlaybackReturnsConflictWhenContentNotReady() throws Exception { .andExpect(jsonPath("$.error.code").value("CONTENT_NOT_PLAYABLE")); } + @Test + void publishReturnsPublishedContent() throws Exception { + ChannelEntity channel = saveChannel("channel-8", "theta-channel"); + saveMembership(channel, "user-8", ChannelMemberRole.ADMIN); + ContentEntity content = saveContent(channel, "Draft", "Ready", ContentVisibility.PRIVATE, ContentState.DRAFT, + true); + + mockMvc.perform(post("/v1/content/{contentId}/publish", content.getId()).contentType(MediaType.APPLICATION_JSON) + .header("X-Request-Id", "req_content_publish_1").content(""" + { + "userId": "user-8" + } + """)).andExpect(status().isOk()).andExpect(jsonPath("$.data.id").value(content.getId())) + .andExpect(jsonPath("$.data.state").value("PUBLISHED")) + .andExpect(jsonPath("$.data.publishedAt").exists()) + .andExpect(jsonPath("$.meta.requestId").value("req_content_publish_1")); + } + + @Test + void publishReturnsConflictWhenNotPlaybackReady() throws Exception { + ChannelEntity channel = saveChannel("channel-9", "iota-channel"); + saveMembership(channel, "user-9", ChannelMemberRole.OWNER); + ContentEntity content = saveContent(channel, "Draft", "Not ready", ContentVisibility.PRIVATE, + ContentState.DRAFT, false); + + mockMvc.perform(post("/v1/content/{contentId}/publish", content.getId()).contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "userId": "user-9" + } + """)).andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CONTENT_NOT_READY")); + } + + @Test + void publishReturnsForbiddenForNonMember() throws Exception { + ChannelEntity channel = saveChannel("channel-10", "kappa-channel"); + ContentEntity content = saveContent(channel, "Draft", "Ready", ContentVisibility.PRIVATE, ContentState.DRAFT, + true); + + mockMvc.perform(post("/v1/content/{contentId}/publish", content.getId()).contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "userId": "user-10" + } + """)).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 6d861db..0413312 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 @@ -132,6 +132,45 @@ void getPlaybackReturnsConflictForUnplayableState() { assertEquals("CONTENT_NOT_PLAYABLE", exception.getCode()); } + @Test + void publishTransitionsDraftToPublishedWhenReady() { + ChannelEntity channel = saveChannel("channel-content-6", "content-channel-six"); + saveMembership(channel, "publisher-1", ChannelMemberRole.OWNER); + ContentEntity content = saveContent(channel, "Ready draft", "desc", ContentVisibility.PRIVATE, + ContentState.DRAFT, true); + + var published = contentService.publish(content.getId(), "publisher-1"); + + assertEquals(ContentState.PUBLISHED, published.state()); + assertNotNull(published.publishedAt()); + } + + @Test + void publishRejectsWhenPlaybackNotReady() { + ChannelEntity channel = saveChannel("channel-content-7", "content-channel-seven"); + saveMembership(channel, "publisher-2", ChannelMemberRole.ADMIN); + ContentEntity content = saveContent(channel, "Not ready", "desc", ContentVisibility.PRIVATE, ContentState.DRAFT, + false); + + ApiException exception = assertThrows(ApiException.class, + () -> contentService.publish(content.getId(), "publisher-2")); + + assertEquals("CONTENT_NOT_READY", exception.getCode()); + } + + @Test + void publishRejectsWhenStateIsNotDraft() { + ChannelEntity channel = saveChannel("channel-content-8", "content-channel-eight"); + saveMembership(channel, "publisher-3", ChannelMemberRole.ADMIN); + ContentEntity content = saveContent(channel, "Already published", "desc", ContentVisibility.PUBLIC, + ContentState.PUBLISHED, true); + + ApiException exception = assertThrows(ApiException.class, + () -> contentService.publish(content.getId(), "publisher-3")); + + assertEquals("CONTENT_STATE_INVALID", exception.getCode()); + } + private ChannelEntity saveChannel(String id, String slug) { ChannelEntity channel = new ChannelEntity(); channel.setId(id);