Skip to content

Commit 6f55575

Browse files
authored
Merge pull request #23 from poyrazK/feat/policy-moderation-api
feat(policy): add moderation state API
2 parents 5f8710d + 432904c commit 6f55575

8 files changed

Lines changed: 199 additions & 4 deletions

File tree

docs/contracts/rest-api-v1.md

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

161161
### `PATCH /v1/moderation/content/{content_id}`
162162
- Applies moderation state (visible, hidden, removed)
163+
- Request supports `moderationState` with values `VISIBLE`, `HIDDEN`, or `REMOVED`
164+
- Reuses the content policy record and preserves existing age/geo fields
163165

164166
### `GET /v1/moderation/comments/reports`
165167
- Lists reported comments for moderator queue

docs/mvp-backend-implementation-plan.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ Current completed slices:
494494

495495
Next active slice:
496496

497-
- Policy-service foundation:
498-
- add `PATCH /v1/policy/content/{content_id}` for age restriction and geo policy updates
499-
- support partial upsert semantics for content policy records
500-
- keep moderation state updates in a follow-up PR
497+
- Policy-service moderation API:
498+
- add `PATCH /v1/moderation/content/{content_id}` for moderation state updates
499+
- preserve existing age/geo policy fields when moderation changes
500+
- keep policy evaluation and downstream enforcement for a follow-up PR
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.cloudmedia.policy.api.policy;
2+
3+
import com.cloudmedia.policy.api.policy.dto.ContentPolicyResponse;
4+
import com.cloudmedia.policy.api.policy.dto.UpdateModerationStateRequest;
5+
import com.cloudmedia.policy.api.response.ApiMeta;
6+
import com.cloudmedia.policy.api.response.ApiSuccessResponse;
7+
import com.cloudmedia.policy.application.ContentPolicyService;
8+
import jakarta.validation.Valid;
9+
import jakarta.validation.constraints.NotBlank;
10+
import java.time.Instant;
11+
import java.util.UUID;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.validation.annotation.Validated;
14+
import org.springframework.web.bind.annotation.PatchMapping;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.RequestBody;
17+
import org.springframework.web.bind.annotation.RequestHeader;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RestController;
20+
21+
@Validated
22+
@RestController
23+
@RequestMapping("/v1/moderation")
24+
public class ModerationController {
25+
26+
private final ContentPolicyService contentPolicyService;
27+
28+
public ModerationController(ContentPolicyService contentPolicyService) {
29+
this.contentPolicyService = contentPolicyService;
30+
}
31+
32+
@PatchMapping("/content/{contentId}")
33+
public ResponseEntity<ApiSuccessResponse<ContentPolicyResponse>> updateModerationState(
34+
@PathVariable("contentId") @NotBlank String contentId,
35+
@Valid @RequestBody UpdateModerationStateRequest request,
36+
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
37+
String effectiveRequestId = requestId != null && !requestId.isBlank() ? requestId : "req_" + UUID.randomUUID();
38+
ContentPolicyResponse response = contentPolicyService.updateModerationState(contentId, request);
39+
return ResponseEntity.ok(new ApiSuccessResponse<>(response, new ApiMeta(effectiveRequestId, Instant.now())));
40+
}
41+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.cloudmedia.policy.api.policy.dto;
2+
3+
import com.cloudmedia.policy.persistence.entity.ModerationState;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record UpdateModerationStateRequest(@NotNull ModerationState moderationState) {
7+
}

services/java/policy-service/src/main/java/com/cloudmedia/policy/application/ContentPolicyService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.cloudmedia.policy.api.policy.dto.ContentPolicyResponse;
44
import com.cloudmedia.policy.api.policy.dto.UpdateContentPolicyRequest;
5+
import com.cloudmedia.policy.api.policy.dto.UpdateModerationStateRequest;
56
import com.cloudmedia.policy.error.ApiException;
67
import com.cloudmedia.policy.persistence.entity.ContentPolicyEntity;
78
import com.cloudmedia.policy.persistence.entity.ModerationState;
@@ -56,6 +57,13 @@ public ContentPolicyResponse updateContentPolicy(String contentId, UpdateContent
5657
return toResponse(contentPolicyRepository.save(entity));
5758
}
5859

60+
@Transactional
61+
public ContentPolicyResponse updateModerationState(String contentId, UpdateModerationStateRequest request) {
62+
ContentPolicyEntity entity = contentPolicyRepository.findById(contentId).orElseGet(() -> newEntity(contentId));
63+
entity.setModerationState(request.moderationState());
64+
return toResponse(contentPolicyRepository.save(entity));
65+
}
66+
5967
private ContentPolicyEntity newEntity(String contentId) {
6068
ContentPolicyEntity entity = new ContentPolicyEntity();
6169
entity.setContentId(contentId);

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.slf4j.LoggerFactory;
1515
import org.springframework.http.HttpStatus;
1616
import org.springframework.http.ResponseEntity;
17+
import org.springframework.http.converter.HttpMessageNotReadableException;
1718
import org.springframework.validation.FieldError;
1819
import org.springframework.web.bind.MethodArgumentNotValidException;
1920
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -50,6 +51,12 @@ public ResponseEntity<ApiErrorResponse> handleApiException(ApiException exceptio
5051
new ApiError(exception.getCode(), exception.getMessage(), Map.of()), responseMeta));
5152
}
5253

54+
@ExceptionHandler(HttpMessageNotReadableException.class)
55+
public ResponseEntity<ApiErrorResponse> handleUnreadableBody(HttpMessageNotReadableException exception) {
56+
return ResponseEntity.badRequest().body(
57+
new ApiErrorResponse(new ApiError("VALIDATION_ERROR", "Malformed request body", Map.of()), meta()));
58+
}
59+
5360
@ExceptionHandler(Exception.class)
5461
public ResponseEntity<ApiErrorResponse> handleUnhandled(Exception exception) {
5562
LOGGER.error("Unhandled exception in ApiExceptionHandler", exception);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.cloudmedia.policy.api.policy;
2+
3+
import com.cloudmedia.policy.persistence.entity.ContentPolicyEntity;
4+
import com.cloudmedia.policy.persistence.entity.ModerationState;
5+
import com.cloudmedia.policy.persistence.repository.ContentPolicyRepository;
6+
import org.junit.jupiter.api.Test;
7+
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
import org.springframework.http.MediaType;
11+
import org.springframework.test.web.servlet.MockMvc;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
15+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
16+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
17+
18+
@SpringBootTest
19+
@AutoConfigureMockMvc
20+
@Transactional
21+
class ModerationControllerTest {
22+
23+
@Autowired
24+
private MockMvc mockMvc;
25+
26+
@Autowired
27+
private ContentPolicyRepository contentPolicyRepository;
28+
29+
@Test
30+
void patchCreatesModerationStateWhenMissing() throws Exception {
31+
mockMvc.perform(patch("/v1/moderation/content/cnt_1").contentType(MediaType.APPLICATION_JSON)
32+
.header("X-Request-Id", "req_moderation_1").content("""
33+
{
34+
"moderationState": "HIDDEN"
35+
}
36+
""")).andExpect(status().isOk()).andExpect(jsonPath("$.data.contentId").value("cnt_1"))
37+
.andExpect(jsonPath("$.data.moderationState").value("HIDDEN"))
38+
.andExpect(jsonPath("$.meta.requestId").value("req_moderation_1"));
39+
}
40+
41+
@Test
42+
void patchUpdatesExistingModerationState() throws Exception {
43+
savePolicy("cnt_2", ModerationState.VISIBLE, "TR", "US");
44+
45+
mockMvc.perform(patch("/v1/moderation/content/cnt_2").contentType(MediaType.APPLICATION_JSON).content("""
46+
{
47+
"moderationState": "REMOVED"
48+
}
49+
""")).andExpect(status().isOk()).andExpect(jsonPath("$.data.moderationState").value("REMOVED"))
50+
.andExpect(jsonPath("$.data.geoAllowList[0]").value("TR"))
51+
.andExpect(jsonPath("$.data.geoBlockList[0]").value("US"));
52+
}
53+
54+
@Test
55+
void patchRejectsInvalidModerationState() throws Exception {
56+
mockMvc.perform(patch("/v1/moderation/content/cnt_3").contentType(MediaType.APPLICATION_JSON).content("""
57+
{
58+
"moderationState": "INVALID"
59+
}
60+
""")).andExpect(status().isBadRequest()).andExpect(jsonPath("$.error.code").value("VALIDATION_ERROR"));
61+
}
62+
63+
private void savePolicy(String contentId, ModerationState moderationState, String allowList, String blockList) {
64+
ContentPolicyEntity entity = new ContentPolicyEntity();
65+
entity.setContentId(contentId);
66+
entity.setGeoAllowList(allowList);
67+
entity.setGeoBlockList(blockList);
68+
entity.setModerationState(moderationState);
69+
contentPolicyRepository.saveAndFlush(entity);
70+
}
71+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.cloudmedia.policy.application;
2+
3+
import com.cloudmedia.policy.api.policy.dto.UpdateModerationStateRequest;
4+
import com.cloudmedia.policy.persistence.entity.ContentPolicyEntity;
5+
import com.cloudmedia.policy.persistence.entity.ModerationState;
6+
import com.cloudmedia.policy.persistence.repository.ContentPolicyRepository;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertTrue;
14+
15+
@SpringBootTest
16+
@Transactional
17+
class ModerationPolicyServiceIntegrationTest {
18+
19+
@Autowired
20+
private ContentPolicyService contentPolicyService;
21+
22+
@Autowired
23+
private ContentPolicyRepository contentPolicyRepository;
24+
25+
@Test
26+
void moderationUpdateCreatesPolicyWhenMissing() {
27+
var response = contentPolicyService.updateModerationState("cnt_1",
28+
new UpdateModerationStateRequest(ModerationState.HIDDEN));
29+
30+
assertEquals("cnt_1", response.contentId());
31+
assertEquals(ModerationState.HIDDEN, response.moderationState());
32+
assertEquals(java.util.List.of(), response.geoAllowList());
33+
assertEquals(java.util.List.of(), response.geoBlockList());
34+
}
35+
36+
@Test
37+
void moderationUpdatePreservesAgeAndGeoFields() {
38+
savePolicy("cnt_2", true, "TR,DE", "US", ModerationState.VISIBLE);
39+
40+
var response = contentPolicyService.updateModerationState("cnt_2",
41+
new UpdateModerationStateRequest(ModerationState.REMOVED));
42+
43+
assertTrue(response.ageRestricted());
44+
assertEquals(java.util.List.of("TR", "DE"), response.geoAllowList());
45+
assertEquals(java.util.List.of("US"), response.geoBlockList());
46+
assertEquals(ModerationState.REMOVED, response.moderationState());
47+
}
48+
49+
private void savePolicy(String contentId, boolean ageRestricted, String allowList, String blockList,
50+
ModerationState moderationState) {
51+
ContentPolicyEntity entity = new ContentPolicyEntity();
52+
entity.setContentId(contentId);
53+
entity.setAgeRestricted(ageRestricted);
54+
entity.setGeoAllowList(allowList);
55+
entity.setGeoBlockList(blockList);
56+
entity.setModerationState(moderationState);
57+
contentPolicyRepository.saveAndFlush(entity);
58+
}
59+
}

0 commit comments

Comments
 (0)