Skip to content
1 change: 1 addition & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"MD012": false,
"MD013": false,
"MD022": false,
"MD032": false,
Expand Down
91 changes: 91 additions & 0 deletions docs/adr/0013-content-listing-thumbnail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# ADR 013: Content Listing and Thumbnail Support

## Status

Accepted

## Date

2026-04-26

## Context

The content-service MVP was missing support for listing channel content and did not have thumbnail URL tracking. The implementation plan specified adding:

1. Channel content listing endpoint (`GET /v1/channels/{channelId}/content`)
2. Thumbnail URL field on content entity and responses

These features are needed for:

- Users viewing all content on a channel
- Displaying video thumbnails in UI
- Integration with discovery/search (which uses thumbnail URLs)

## Decision

### Database Schema

Added `thumbnail_url` column to the `content` table via Flyway migration:

```sql
ALTER TABLE content ADD COLUMN thumbnail_url varchar(512);
```

### API Endpoint

Added `GET /v1/channels/{channelId}/content` endpoint to ChannelController with optional `state` query parameter for filtering by ContentState.

### Response Format

ContentResponse DTO extended to include `thumbnailUrl` field:

```java
public record ContentResponse(..., String thumbnailUrl) { }
```

### Implementation Details

- Repository already had `findByChannel_Id()` method - no changes needed
- State filtering uses existing `findByChannel_IdAndState()` method
- 404 returned when channel does not exist
- Results ordered by `created_at` ascending (chronological order)

## Consequences

### Positive

- Channel content is now queryable via REST API
- Thumbnail URLs can be stored and retrieved
- Consistent with existing API response patterns
- Ready for discovery service integration

### Negative

- Additional database column increases storage
- More fields to maintain in migrations

### Neutral

- Existing content endpoints unchanged
- Backward compatible (thumbnailUrl is optional)

## Alternatives Considered

### Alternative 1: Separate Content Listing Controller

Create a new `ChannelContentController` instead of adding to existing `ChannelController`.

**Why rejected:** Keeping listing in `ChannelController` maintains consistency with other channel-scoped endpoints and avoids unnecessary controller proliferation.

### Alternative 2: Pagination

Implement cursor-based pagination for content listing.

**Why rejected:** MVP scope kept simple. Pagination can be added when volume warrants it per the roadmap's TODO note in the REST API contract.

## Implementation Notes

- Migration file: `V3__add_content_thumbnail.sql`
- New tests: 7 tests added (5 integration + 2 web MVC)
- Total test count: 50 (all passing)

38 changes: 38 additions & 0 deletions docs/contracts/rest-api-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,30 @@ This document defines the MVP API contract groups, standards, and core request/r
## 2) Auth and identity

### `POST /v1/auth/login`

- Request: email/password
- Response: access token + refresh token + session id

### `POST /v1/auth/social-login`

- Request: provider, provider_token, optional device info
- MVP provider support: `GOOGLE` only
- Current implementation uses a fake verifier for development/testing only.
- Fake token format: `fake-google:<subject>:<email>`
- Response: access token + refresh token + session id

### `POST /v1/auth/refresh`

- Request: refresh token
- Response: new access token + new refresh token (rotation)

### `POST /v1/auth/logout`

- Request: current session or all sessions flag
- Response: success status

### 2.1 Identity MVP implementation notes

- Access token TTL: `15 minutes`
- Refresh token TTL: `30 days`
- Refresh strategy: rotation on every refresh request
Expand All @@ -69,23 +74,28 @@ This document defines the MVP API contract groups, standards, and core request/r
## 3) Upload and content

### `POST /v1/uploads/sessions`

- Creates resumable upload session
- Validates creator quota and content type

### `POST /v1/uploads/sessions/{session_id}/finalize`

- Marks upload complete and emits `upload.completed`

### `POST /v1/content`

- Creates content metadata record in draft state
- Request fields (MVP): `userId`, `channelId`, `title`, `description`, `contentType`, optional `visibility`
- Default behavior: `state=DRAFT`, `playbackReady=false`, `publishedAt=null`, `visibility=PRIVATE` when omitted

### `PATCH /v1/content/{content_id}`

- Partially updates content metadata for channel members
- Mutable fields (MVP): `title`, `description`, `visibility`
- Emits `content.updated` event when content is in `PUBLISHED` state

### `POST /v1/content/{content_id}/publish`

- Publishes content from `DRAFT` to `PUBLISHED`
- Request fields (MVP): `userId`
- Requires `playbackReady=true`
Expand All @@ -94,39 +104,55 @@ This document defines the MVP API contract groups, standards, and core request/r
- 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`

### `GET /v1/content/{content_id}/playback`

- Returns signed manifest URL and available renditions
- Applies age/geo/moderation checks
- Query params (MVP): optional `countryCode` (2-letter code), optional `ageVerified` (`true|false`)
- Returns `403 CONTENT_POLICY_DENIED` when policy blocks playback

### `GET /v1/channels/{channel_id}/content`

- Returns list of content for a channel
- Query params: optional `state` (filter by ContentState: DRAFT, PUBLISHED, PRIVATE)
- Returns `404 CHANNEL_NOT_FOUND` when channel does not exist
- Response includes `thumbnailUrl` field per content item

## 4) Social

### `POST /v1/social/follows/{channel_id}`

- Follow channel

### `DELETE /v1/social/follows/{channel_id}`

- Unfollow channel

### `POST /v1/social/comments`

- Creates comment (blocked-word filter at write-time)

### `PATCH /v1/social/comments/{comment_id}`

- Edits comment and stores revision record

### `POST /v1/social/comments/{comment_id}/reports`

- Files moderation report

### `POST /v1/social/playlists`

- Creates playlist

## 5) Discovery and search

### `GET /v1/search`

- Keyword search over OpenSearch-backed index
- MVP exception to the global cursor-pagination rule: uses `q`, `page`, `size`
- Current semantics: `page` is 0-based, `size` max is `100`
Expand All @@ -136,54 +162,66 @@ This document defines the MVP API contract groups, standards, and core request/r
- TODO: migrate search results to cursor-based pagination after the initial read API stabilizes

### `GET /v1/search/autocomplete`

- Title suggestion endpoint over the derived search index
- MVP params: required non-blank `q`; optional integer `size` with min `1`, default `5`, max `10`

### `GET /v1/discovery/home`

- Balanced feed (followed + trending + fresh + similar)
- MVP params: optional `userId`; optional integer `size` with min `1`, default `20`, max `50`; optional `countryCode` (ISO 3166-1 alpha-2 uppercase, e.g. `US`); optional `ageVerified`
- Returns a generic blended feed when `userId` is absent
- Filters out policy-blocked items from the blended feed
- Returns `503 POLICY_SERVICE_UNAVAILABLE` when policy evaluation fails

### `GET /v1/discovery/trending`

- Region-level trending feed

## 6) Livestream and chat

### `POST /v1/live/streams`

- Creates stream with ingest credentials

### `POST /v1/live/streams/{stream_id}/start`

- Starts stream session

### `POST /v1/live/streams/{stream_id}/end`

- Ends stream and triggers replay job

### `GET /v1/live/streams/{stream_id}/replay-status`

- Returns replay processing status and resulting content id

### `POST /v1/chat/rooms/{room_id}/messages`

- Sends chat message
- Applies anti-spam and moderation hooks

## 7) Policy and moderation

### `PATCH /v1/policy/content/{content_id}`

- Updates age restriction, geo allow/block rules
- Request supports partial upsert of `ageRestricted`, `geoAllowList`, and `geoBlockList`
- Omitted fields remain unchanged; empty geo lists clear that specific list

### `PATCH /v1/moderation/content/{content_id}`

- Applies moderation state (visible, hidden, removed)
- Request supports `moderationState` with values `VISIBLE`, `HIDDEN`, or `REMOVED`
- Reuses the content policy record and preserves existing age/geo fields

### `POST /v1/policy/content/{content_id}/evaluate`

- Evaluates whether content is allowed for viewing/playback in a given request context
- Request supports optional `countryCode` and optional `ageVerified`

### `GET /v1/moderation/comments/reports`

- Lists reported comments for moderator queue

## 8) HTTP status usage
Expand Down
5 changes: 3 additions & 2 deletions docs/modular-implementation-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ This roadmap breaks implementation into small, reviewable slices with one primar
- Phase A (done): persistence foundation (Flyway migrations, JPA entities, repositories, repository tests).
- Phase B (done): channel APIs (explicit create/list/get).
- Phase C (done): content draft/update APIs.
- Phase D (next): publish/unpublish workflow with playback-ready guard.
- Phase D (done): publish/unpublish workflow with playback-ready guard + content listing + thumbnail URL support.

### PR-005: policy-service MVP
- Phase A (next): service foundation, persistence model, and error/API baseline.
Expand Down Expand Up @@ -87,4 +87,5 @@ This roadmap breaks implementation into small, reviewable slices with one primar
- PR-001.5: completed
- PR-002: completed
- PR-003: completed
- PR-004: in progress (Phases A, B, and C complete)
- PR-004: completed (Phases A, B, C, and D all complete - content listing + thumbnail support added)
- PR-005: next (policy-service MVP)
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import com.cloudmedia.content.api.channel.dto.ChannelResponse;
import com.cloudmedia.content.api.channel.dto.CreateChannelRequest;
import com.cloudmedia.content.api.channel.dto.UserChannelResponse;
import com.cloudmedia.content.api.content.dto.ContentResponse;
import com.cloudmedia.content.api.response.ApiMeta;
import com.cloudmedia.content.api.response.ApiSuccessResponse;
import com.cloudmedia.content.application.channel.ChannelService;
import com.cloudmedia.content.application.content.ContentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.time.Instant;
Expand All @@ -19,17 +21,21 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.cloudmedia.content.persistence.entity.ContentState;

@Validated
@RestController
@RequestMapping("/v1")
public class ChannelController {

private final ChannelService channelService;
private final ContentService contentService;

public ChannelController(ChannelService channelService) {
public ChannelController(ChannelService channelService, ContentService contentService) {
this.channelService = channelService;
this.contentService = contentService;
}

@PostMapping("/channels")
Expand Down Expand Up @@ -64,6 +70,15 @@ public ResponseEntity<ApiSuccessResponse<List<UserChannelResponse>>> listChannel
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
}

@GetMapping("/channels/{channelId}/content")
public ResponseEntity<ApiSuccessResponse<List<ContentResponse>>> listChannelContent(
@PathVariable("channelId") @NotBlank String channelId,
@RequestParam(value = "state", required = false) ContentState state,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
List<ContentResponse> response = contentService.listByChannel(channelId, state);
return ResponseEntity.ok(new ApiSuccessResponse<>(response, meta(requestId)));
}

private ApiMeta meta(String requestIdHeader) {
String requestId = requestIdHeader != null && !requestIdHeader.isBlank()
? requestIdHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@

public record ContentResponse(String id, String channelId, String title, String description, ContentType contentType,
ContentState state, ContentVisibility visibility, boolean playbackReady, LocalDateTime createdAt,
LocalDateTime updatedAt, LocalDateTime publishedAt) {
LocalDateTime updatedAt, LocalDateTime publishedAt, String thumbnailUrl) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,28 @@
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.net.MalformedURLException;
import java.net.URL;

public record UpdateContentRequest(@NotBlank @Size(max = 36) String userId,
@Size(max = 255) @Pattern(regexp = ".*\\S.*", message = "must not be blank") String title,
@Size(max = 4000) String description, ContentVisibility visibility) {
@Size(max = 4000) String description, ContentVisibility visibility, @Size(max = 512) String thumbnailUrl) {

@AssertTrue(message = "At least one field must be provided for update")
public boolean hasUpdatableField() {
return title != null || description != null || visibility != null;
return title != null || description != null || visibility != null || thumbnailUrl != null;
}

@AssertTrue(message = "thumbnailUrl must be a valid URL")
public boolean isThumbnailUrlValid() {
if (thumbnailUrl == null || thumbnailUrl.isBlank()) {
return true;
}
try {
new URL(thumbnailUrl);
return true;
} catch (MalformedURLException e) {
return false;
}
}
}
Loading
Loading