Skip to content

feat(content): add published-to-private unpublish endpoint#30

Merged
poyrazK merged 3 commits intomainfrom
feat/content-unpublish-workflow
Apr 6, 2026
Merged

feat(content): add published-to-private unpublish endpoint#30
poyrazK merged 3 commits intomainfrom
feat/content-unpublish-workflow

Conversation

@poyrazK
Copy link
Copy Markdown
Owner

@poyrazK poyrazK commented Apr 6, 2026

Summary

  • add POST /v1/content/{contentId}/unpublish and route it through content-service
  • implement unpublish state transition guardrails: membership required, only PUBLISHED can be unpublished, and transition target is PRIVATE
  • add controller/service test coverage for unpublish success plus conflict/forbidden paths and update REST contract docs

Verification

  • mvn -pl content-service spotless:apply checkstyle:check
  • mvn -pl content-service test

Summary by CodeRabbit

Release Notes

  • New Features
    • Added unpublish functionality to revert published content back to private state, preserving the original publication timestamp.
    • Includes validation to prevent unpublishing non-published content and access control restrictions for non-members.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

Added a new REST API endpoint POST /v1/content/{contentId}/unpublish to transition published content to private state. Implementation includes API contract documentation, controller endpoint, service business logic with state validation, and comprehensive test coverage across controller and integration tests.

Changes

Cohort / File(s) Summary
API Contract
docs/contracts/rest-api-v1.md
Documented new POST /v1/content/{content_id}/unpublish endpoint with MVP request fields (userId) and conflict error (409 CONTENT_STATE_INVALID) for invalid state transitions.
Controller Implementation
services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java
Added public endpoint method unpublishContent() accepting contentId path variable and PublishContentRequest body, with optional X-Request-Id header; delegates to contentService.unpublish() and wraps response.
Service Business Logic
services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java
Implemented unpublish(contentId, userId) transactional method verifying channel membership, validating content state is PUBLISHED, transitioning to PRIVATE, and persisting changes while preserving publishedAt timestamp.
Test Coverage
services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java, services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java
Added five test cases covering successful unpublish transition, 409 CONTENT_STATE_INVALID conflict response, 403 CHANNEL_ACCESS_DENIED forbidden response, and integration tests for state preservation.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller
    participant Service
    participant Repository
    participant Database

    Client->>Controller: POST /v1/content/{contentId}/unpublish<br/>{userId}
    Controller->>Controller: Validate contentId (non-blank)
    Controller->>Service: unpublish(contentId, userId)
    Service->>Repository: findById(contentId)
    Repository->>Database: Query content
    Database-->>Repository: ContentEntity
    Repository-->>Service: ContentEntity
    Service->>Service: Verify channel membership
    alt Member verification fails
        Service-->>Controller: ApiException(CHANNEL_ACCESS_DENIED)
    else State not PUBLISHED
        Service-->>Controller: ApiException(CONTENT_STATE_INVALID)
    else Valid transition
        Service->>Service: Set state=PRIVATE, update updatedAt
        Service->>Repository: save(contentEntity)
        Repository->>Database: Persist changes
        Database-->>Repository: Updated
        Repository-->>Service: ContentEntity
        Service-->>Controller: ContentResponse
    end
    Controller-->>Client: ApiSuccessResponse {data, meta}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A hop, skip and bound through the code we go,
Unpublishing content with a graceful flow,
From PUBLISHED to PRIVATE, the state takes flight,
With tests all in place, everything's right!
✨ The service leaps forward, the endpoint's alive!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly and clearly describes the main change: adding a new endpoint that unpublishes content from published to private state.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/content-unpublish-workflow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java (1)

64-66: Prefer a dedicated request DTO for unpublish.

Line 64 currently reuses PublishContentRequest. It works now, but it couples two different endpoint contracts. A separate UnpublishContentRequest keeps the API surface independent and safer for future changes.

🤖 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/api/content/ContentController.java`
around lines 64 - 66, The controller currently reuses PublishContentRequest for
the unpublish endpoint in ContentController; create a new DTO
UnpublishContentRequest (with the same fields currently needed, e.g., userId)
and update the method signature to accept `@Valid` `@RequestBody`
UnpublishContentRequest request and pass request.userId() into
contentService.unpublish(contentId, request.userId()); ensure the new class is
placed with other request DTOs and annotated/validated the same way as
PublishContentRequest so the endpoint contract is decoupled.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/contracts/rest-api-v1.md`:
- Around line 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).

In
`@services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java`:
- Around line 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.

---

Nitpick comments:
In
`@services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java`:
- Around line 64-66: The controller currently reuses PublishContentRequest for
the unpublish endpoint in ContentController; create a new DTO
UnpublishContentRequest (with the same fields currently needed, e.g., userId)
and update the method signature to accept `@Valid` `@RequestBody`
UnpublishContentRequest request and pass request.userId() into
contentService.unpublish(contentId, request.userId()); ensure the new class is
placed with other request DTOs and annotated/validated the same way as
PublishContentRequest so the endpoint contract is decoupled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5592bb07-5b38-4dbf-b10e-bac253f44475

📥 Commits

Reviewing files that changed from the base of the PR and between 0a8c560 and 1ac50d1.

📒 Files selected for processing (5)
  • docs/contracts/rest-api-v1.md
  • services/java/content-service/src/main/java/com/cloudmedia/content/api/content/ContentController.java
  • services/java/content-service/src/main/java/com/cloudmedia/content/application/content/ContentService.java
  • services/java/content-service/src/test/java/com/cloudmedia/content/api/content/ContentControllerWebMvcTest.java
  • services/java/content-service/src/test/java/com/cloudmedia/content/application/content/ContentServiceIntegrationTest.java

Comment on lines +95 to +98
- Unpublishes content from `PUBLISHED` to `PRIVATE`
- Request fields (MVP): `userId`
- Returns `409 CONTENT_STATE_INVALID` when state is not `PUBLISHED`

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).

Comment on lines +112 to +120
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));
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.

@poyrazK poyrazK merged commit 90830ad into main Apr 6, 2026
6 checks passed
@poyrazK poyrazK deleted the feat/content-unpublish-workflow branch April 6, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant