Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/contracts/rest-api-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,14 @@ public ResponseEntity<ApiSuccessResponse<ContentResponse>> updateContentMetadata
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
}

@PostMapping("/content/{contentId}/publish")
public ResponseEntity<ApiSuccessResponse<ContentResponse>> 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<ApiSuccessResponse<PlaybackResponse>> getPlayback(
@PathVariable("contentId") @NotBlank String contentId,
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading