Skip to content

Commit dcfbcb9

Browse files
authored
Merge pull request #12 from poyrazK/feat/content-draft-update-apis
feat(content): add draft and metadata update APIs
2 parents 5c48d0f + 00ad166 commit dcfbcb9

11 files changed

Lines changed: 475 additions & 3 deletions

File tree

docs/contracts/rest-api-v1.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ This document defines the MVP API contract groups, standards, and core request/r
7777

7878
### `POST /v1/content`
7979
- Creates content metadata record in draft state
80+
- Request fields (MVP): `userId`, `channelId`, `title`, `description`, `contentType`, optional `visibility`
81+
- Default behavior: `state=DRAFT`, `playbackReady=false`, `publishedAt=null`, `visibility=PRIVATE` when omitted
82+
83+
### `PATCH /v1/content/{content_id}`
84+
- Partially updates content metadata for channel members
85+
- Mutable fields (MVP): `title`, `description`, `visibility`
8086

8187
### `POST /v1/content/{content_id}/publish`
8288
- Idempotent publish request

docs/modular-implementation-roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ This roadmap breaks implementation into small, reviewable slices with one primar
3131
### PR-004: content-service MVP
3232
- Phase A (done): persistence foundation (Flyway migrations, JPA entities, repositories, repository tests).
3333
- Phase B (done): channel APIs (explicit create/list/get).
34-
- Phase C (next): content draft/update APIs.
34+
- Phase C (done): content draft/update APIs.
3535
- Phase D (next): publish/unpublish workflow with playback-ready guard.
3636

3737
### PR-005: policy-service MVP
@@ -79,4 +79,4 @@ This roadmap breaks implementation into small, reviewable slices with one primar
7979
- PR-001: completed
8080
- PR-002: completed
8181
- PR-003: completed
82-
- PR-004: in progress (Phases A and B complete)
82+
- PR-004: in progress (Phases A, B, and C complete)

docs/mvp-backend-implementation-plan.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,13 @@ Current completed slices:
478478
- explicit channel create endpoint with owner membership assignment
479479
- channel lookup by id and slug
480480
- user channel listing endpoint
481+
- Content-service draft/update metadata APIs are implemented:
482+
- `POST /v1/content` creates content in `DRAFT`
483+
- `PATCH /v1/content/{content_id}` updates metadata fields
484+
- channel membership checks enforced for create/update
481485

482486
Next active slice:
483487

484488
- Content-service API foundation:
485-
- content draft/update endpoints
486489
- publish/unpublish lifecycle endpoints
487490
- publish guard requiring playable renditions readiness
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.cloudmedia.content.api.content;
2+
3+
import com.cloudmedia.content.api.content.dto.ContentResponse;
4+
import com.cloudmedia.content.api.content.dto.CreateContentRequest;
5+
import com.cloudmedia.content.api.content.dto.UpdateContentRequest;
6+
import com.cloudmedia.content.api.response.ApiMeta;
7+
import com.cloudmedia.content.api.response.ApiSuccessResponse;
8+
import com.cloudmedia.content.application.content.ContentService;
9+
import jakarta.validation.Valid;
10+
import jakarta.validation.constraints.NotBlank;
11+
import java.time.Instant;
12+
import java.util.UUID;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.validation.annotation.Validated;
15+
import org.springframework.web.bind.annotation.PatchMapping;
16+
import org.springframework.web.bind.annotation.PathVariable;
17+
import org.springframework.web.bind.annotation.PostMapping;
18+
import org.springframework.web.bind.annotation.RequestBody;
19+
import org.springframework.web.bind.annotation.RequestHeader;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RestController;
22+
23+
@Validated
24+
@RestController
25+
@RequestMapping("/v1")
26+
public class ContentController {
27+
28+
private final ContentService contentService;
29+
30+
public ContentController(ContentService contentService) {
31+
this.contentService = contentService;
32+
}
33+
34+
@PostMapping("/content")
35+
public ResponseEntity<ApiSuccessResponse<ContentResponse>> createContentDraft(
36+
@Valid @RequestBody CreateContentRequest request,
37+
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
38+
ContentResponse response = contentService.createDraft(request);
39+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
40+
}
41+
42+
@PatchMapping("/content/{contentId}")
43+
public ResponseEntity<ApiSuccessResponse<ContentResponse>> updateContentMetadata(
44+
@PathVariable("contentId") @NotBlank String contentId, @Valid @RequestBody UpdateContentRequest request,
45+
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
46+
ContentResponse response = contentService.updateMetadata(contentId, request);
47+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
48+
}
49+
50+
private ApiMeta meta(String requestIdHeader) {
51+
String requestId = requestIdHeader != null && !requestIdHeader.isBlank()
52+
? requestIdHeader
53+
: "req_" + UUID.randomUUID();
54+
return new ApiMeta(requestId, Instant.now());
55+
}
56+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.cloudmedia.content.api.content.dto;
2+
3+
import com.cloudmedia.content.persistence.entity.ContentState;
4+
import com.cloudmedia.content.persistence.entity.ContentType;
5+
import com.cloudmedia.content.persistence.entity.ContentVisibility;
6+
import java.time.LocalDateTime;
7+
8+
public record ContentResponse(String id, String channelId, String title, String description, ContentType contentType,
9+
ContentState state, ContentVisibility visibility, boolean playbackReady, LocalDateTime createdAt,
10+
LocalDateTime updatedAt, LocalDateTime publishedAt) {
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.cloudmedia.content.api.content.dto;
2+
3+
import com.cloudmedia.content.persistence.entity.ContentType;
4+
import com.cloudmedia.content.persistence.entity.ContentVisibility;
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.NotBlank;
7+
import jakarta.validation.constraints.Size;
8+
9+
public record CreateContentRequest(@NotBlank @Size(max = 36) String userId, @NotBlank @Size(max = 36) String channelId,
10+
@NotBlank @Size(max = 255) String title, @Size(max = 4000) String description, @NotNull ContentType contentType,
11+
ContentVisibility visibility) {
12+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.cloudmedia.content.api.content.dto;
2+
3+
import com.cloudmedia.content.persistence.entity.ContentVisibility;
4+
import jakarta.validation.constraints.AssertTrue;
5+
import jakarta.validation.constraints.NotBlank;
6+
import jakarta.validation.constraints.Pattern;
7+
import jakarta.validation.constraints.Size;
8+
9+
public record UpdateContentRequest(@NotBlank @Size(max = 36) String userId,
10+
@Size(max = 255) @Pattern(regexp = ".*\\S.*", message = "must not be blank") String title,
11+
@Size(max = 4000) String description, ContentVisibility visibility) {
12+
13+
@AssertTrue(message = "At least one field must be provided for update")
14+
public boolean hasUpdatableField() {
15+
return title != null || description != null || visibility != null;
16+
}
17+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.cloudmedia.content.application.content;
2+
3+
import com.cloudmedia.content.api.content.dto.ContentResponse;
4+
import com.cloudmedia.content.api.content.dto.CreateContentRequest;
5+
import com.cloudmedia.content.api.content.dto.UpdateContentRequest;
6+
import com.cloudmedia.content.error.ApiException;
7+
import com.cloudmedia.content.persistence.entity.ChannelEntity;
8+
import com.cloudmedia.content.persistence.entity.ContentEntity;
9+
import com.cloudmedia.content.persistence.entity.ContentState;
10+
import com.cloudmedia.content.persistence.entity.ContentVisibility;
11+
import com.cloudmedia.content.persistence.repository.ChannelMemberRepository;
12+
import com.cloudmedia.content.persistence.repository.ChannelRepository;
13+
import com.cloudmedia.content.persistence.repository.ContentRepository;
14+
import java.time.LocalDateTime;
15+
import java.util.UUID;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
@Service
21+
public class ContentService {
22+
23+
private final ContentRepository contentRepository;
24+
private final ChannelRepository channelRepository;
25+
private final ChannelMemberRepository channelMemberRepository;
26+
27+
public ContentService(ContentRepository contentRepository, ChannelRepository channelRepository,
28+
ChannelMemberRepository channelMemberRepository) {
29+
this.contentRepository = contentRepository;
30+
this.channelRepository = channelRepository;
31+
this.channelMemberRepository = channelMemberRepository;
32+
}
33+
34+
@Transactional
35+
public ContentResponse createDraft(CreateContentRequest request) {
36+
ChannelEntity channel = channelRepository.findById(request.channelId()).orElseThrow(
37+
() -> new ApiException(HttpStatus.NOT_FOUND, "CHANNEL_NOT_FOUND", "Channel not found", null));
38+
assertMember(request.channelId(), request.userId());
39+
40+
LocalDateTime now = LocalDateTime.now();
41+
ContentEntity content = new ContentEntity();
42+
content.setId(UUID.randomUUID().toString());
43+
content.setChannel(channel);
44+
content.setTitle(request.title());
45+
content.setDescription(request.description());
46+
content.setContentType(request.contentType());
47+
content.setState(ContentState.DRAFT);
48+
content.setVisibility(request.visibility() == null ? ContentVisibility.PRIVATE : request.visibility());
49+
content.setPlaybackReady(false);
50+
content.setCreatedAt(now);
51+
content.setUpdatedAt(now);
52+
content.setPublishedAt(null);
53+
54+
return toResponse(contentRepository.save(content));
55+
}
56+
57+
@Transactional
58+
public ContentResponse updateMetadata(String contentId, UpdateContentRequest request) {
59+
ContentEntity content = contentRepository.findById(contentId).orElseThrow(
60+
() -> new ApiException(HttpStatus.NOT_FOUND, "CONTENT_NOT_FOUND", "Content not found", null));
61+
assertMember(content.getChannel().getId(), request.userId());
62+
63+
if (request.title() != null) {
64+
content.setTitle(request.title());
65+
}
66+
if (request.description() != null) {
67+
content.setDescription(request.description());
68+
}
69+
if (request.visibility() != null) {
70+
content.setVisibility(request.visibility());
71+
}
72+
content.setUpdatedAt(LocalDateTime.now());
73+
74+
return toResponse(contentRepository.save(content));
75+
}
76+
77+
private void assertMember(String channelId, String userId) {
78+
if (channelMemberRepository.findByChannel_IdAndUserId(channelId, userId).isEmpty()) {
79+
throw new ApiException(HttpStatus.FORBIDDEN, "CHANNEL_ACCESS_DENIED", "User is not a member of the channel",
80+
null);
81+
}
82+
}
83+
84+
private ContentResponse toResponse(ContentEntity content) {
85+
return new ContentResponse(content.getId(), content.getChannel().getId(), content.getTitle(),
86+
content.getDescription(), content.getContentType(), content.getState(), content.getVisibility(),
87+
content.isPlaybackReady(), content.getCreatedAt(), content.getUpdatedAt(), content.getPublishedAt());
88+
}
89+
}

services/java/content-service/src/main/java/com/cloudmedia/content/error/ApiExceptionHandler.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.springframework.http.HttpStatus;
1212
import org.springframework.http.ResponseEntity;
1313
import org.springframework.validation.FieldError;
14+
import org.springframework.http.converter.HttpMessageNotReadableException;
1415
import org.springframework.web.bind.MethodArgumentNotValidException;
1516
import org.springframework.web.bind.annotation.ExceptionHandler;
1617
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -48,6 +49,13 @@ public ResponseEntity<ApiErrorResponse> handleApiException(ApiException exceptio
4849
return ResponseEntity.status(exception.getStatus()).body(response);
4950
}
5051

52+
@ExceptionHandler(HttpMessageNotReadableException.class)
53+
public ResponseEntity<ApiErrorResponse> handleUnreadableBody(HttpMessageNotReadableException exception) {
54+
ApiErrorResponse response = new ApiErrorResponse(
55+
new ApiError("VALIDATION_ERROR", "Malformed request body", Map.of()), meta());
56+
return ResponseEntity.badRequest().body(response);
57+
}
58+
5159
@ExceptionHandler(Exception.class)
5260
public ResponseEntity<ApiErrorResponse> handleUnhandled(Exception exception) {
5361
ApiErrorResponse response = new ApiErrorResponse(

0 commit comments

Comments
 (0)