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
5 changes: 5 additions & 0 deletions docs/contracts/rest-api-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Comment on lines +95 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the membership failure case (403 CHANNEL_ACCESS_DENIED).

This endpoint is membership-protected in implementation, but the contract currently only documents the 409 case. Add 403 CHANNEL_ACCESS_DENIED so client behavior is fully specified.

📝 Suggested doc patch
 ### `POST /v1/content/{content_id}/unpublish`
 - Unpublishes content from `PUBLISHED` to `PRIVATE`
 - Request fields (MVP): `userId`
+- Returns `403 CHANNEL_ACCESS_DENIED` when user is not a channel member
 - Returns `409 CONTENT_STATE_INVALID` when state is not `PUBLISHED`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Unpublishes content from `PUBLISHED` to `PRIVATE`
- Request fields (MVP): `userId`
- Returns `409 CONTENT_STATE_INVALID` when state is not `PUBLISHED`
- Unpublishes content from `PUBLISHED` to `PRIVATE`
- Request fields (MVP): `userId`
- Returns `403 CHANNEL_ACCESS_DENIED` when user is not a channel member
- Returns `409 CONTENT_STATE_INVALID` when state is not `PUBLISHED`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/contracts/rest-api-v1.md` around lines 95 - 98, The API contract for the
"Unpublish content" endpoint only lists the 409 CONTENT_STATE_INVALID error; add
a new 403 CHANNEL_ACCESS_DENIED response entry to the same endpoint
documentation (the bullet list that starts with "Unpublishes content from
`PUBLISHED` to `PRIVATE`") describing that the request is rejected when the
calling member lacks channel membership/permission; ensure the new entry mirrors
the style of the existing error bullets and clearly names the error code `403
CHANNEL_ACCESS_DENIED` and the condition (membership/permission failure).

### `GET /v1/content/{content_id}/playback`
- Returns signed manifest URL and available renditions
- Applies age/geo/moderation checks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public ResponseEntity<ApiSuccessResponse<ContentResponse>> publishContent(
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
}

@PostMapping("/content/{contentId}/unpublish")
public ResponseEntity<ApiSuccessResponse<ContentResponse>> 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<ApiSuccessResponse<PlaybackResponse>> getPlayback(
@PathVariable("contentId") @NotBlank String contentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment on lines +112 to +120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the state transition atomic to preserve guardrails under concurrency.

Line 112 and Line 117 perform check-then-write in separate steps; concurrent requests can both pass the PUBLISHED check and succeed. Use optimistic locking (@Version) or a conditional update (...WHERE id=? AND state='PUBLISHED') and fail when no row is updated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java`
around lines 112 - 120, The check-then-write in ContentService (the method
performing content.getState() check and contentRepository.save(content)) is
racy; make the unpublish/state transition atomic by either adding optimistic
locking to the Content entity (add a `@Version` field and let save throw
OptimisticLockingFailureException which you translate to an ApiException) or
implement a conditional repository update (e.g. a ContentRepository method like
updateStateIfCurrent(Long id, ContentState newState, ContentState expectedState,
LocalDateTime updatedAt) that executes "UPDATE ... WHERE id=? AND state=?" and
returns the updated row count), then call that from the same service method and
throw the HttpStatus.CONFLICT ApiException when the update count is 0. Ensure
you update updatedAt as part of the atomic DB update and handle/translate any
concurrency exceptions into the same conflict ApiException.

}

@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 @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading