From a8883188f22ca3ac5a30bab09c87cd585d3d7de0 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Tue, 5 May 2026 14:56:03 +0530 Subject: [PATCH 1/2] Done with Reporting system --- REPORT_LLD_INTEGRATION_PLAN.md | 573 +++++++++++ ReportSystem.java | 965 ++++++++++++++++++ .../dto/ContentModerationResponseDto.java | 6 + .../service/ContentModerationService.java | 142 ++- .../post/controller/PostController.java | 10 + .../report/controller/ReportController.java | 93 ++ .../report/dto/CreateReportRequest.java | 26 + .../report/dto/ModerateReportRequest.java | 15 + .../report/entity/ModerationReport.java | 165 +++ .../report/entity/ReportAuditEntry.java | 31 + .../report/enums/ModerationAction.java | 11 + .../report/enums/ReportReason.java | 10 + .../report/enums/ReportStatus.java | 8 + .../report/enums/ReportTargetType.java | 7 + .../report/factory/ReportFactory.java | 33 + .../moderation/AiModerationHandler.java | 71 ++ .../moderation/HumanModerationHandler.java | 54 + .../ModerationExecutionContext.java | 23 + .../report/moderation/ModerationHandler.java | 19 + .../ModerationReportRepository.java | 26 + .../report/resolver/ReportTargetResolver.java | 86 ++ .../report/resolver/ResolvedReportTarget.java | 17 + .../report/service/ReportActionService.java | 188 ++++ .../report/service/ReportService.java | 160 +++ .../NotDuplicateReportSpecification.java | 25 + .../NotSelfReportSpecification.java | 17 + .../ReportValidationContext.java | 17 + .../ReporterNotBlacklistedSpecification.java | 17 + .../report/specification/Specification.java | 8 + .../TargetNotBlacklistedSpecification.java | 25 + .../ContentModerationServiceTests.java | 98 ++ .../post/controller/PostControllerTests.java | 168 +++ .../report/ReportActionServiceTests.java | 335 ++++++ .../report/ReportControllerSecurityTests.java | 40 + .../report/ReportModerationTests.java | 220 ++++ .../report/ReportSpecificationTests.java | 120 +++ 36 files changed, 3816 insertions(+), 13 deletions(-) create mode 100644 REPORT_LLD_INTEGRATION_PLAN.md create mode 100644 ReportSystem.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/enums/ModerationAction.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/enums/ReportReason.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/enums/ReportStatus.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/enums/ReportTargetType.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/moderation/HumanModerationHandler.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/moderation/ModerationExecutionContext.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/moderation/ModerationHandler.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/resolver/ReportTargetResolver.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/resolver/ResolvedReportTarget.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/service/ReportActionService.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/service/ReportService.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/NotDuplicateReportSpecification.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/NotSelfReportSpecification.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/ReportValidationContext.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/ReporterNotBlacklistedSpecification.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/Specification.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/specification/TargetNotBlacklistedSpecification.java create mode 100644 src/test/java/com/cadac/stone_inscription/moderation/ContentModerationServiceTests.java create mode 100644 src/test/java/com/cadac/stone_inscription/post/controller/PostControllerTests.java create mode 100644 src/test/java/com/cadac/stone_inscription/report/ReportActionServiceTests.java create mode 100644 src/test/java/com/cadac/stone_inscription/report/ReportControllerSecurityTests.java create mode 100644 src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java create mode 100644 src/test/java/com/cadac/stone_inscription/report/ReportSpecificationTests.java diff --git a/REPORT_LLD_INTEGRATION_PLAN.md b/REPORT_LLD_INTEGRATION_PLAN.md new file mode 100644 index 0000000..db22800 --- /dev/null +++ b/REPORT_LLD_INTEGRATION_PLAN.md @@ -0,0 +1,573 @@ +# Report System LLD Integration Plan + +## 1. Brief Description of What This LLD Needs + +The provided `ReportSystem.java` is a reference Low-Level Design for a full reporting and moderation workflow. It is not meant to be copied directly into the current backend as-is, because it uses plain Java classes and in-memory repositories, but it gives us the right design direction. + +What the LLD is trying to achieve: + +- allow a user to report a `Post`, `Comment`, or `User` +- validate the report before creation +- create reports through a factory instead of constructing them directly in controllers +- push every report through a moderation pipeline +- let AI handle obvious cases first +- escalate uncertain cases to human moderation +- maintain a controlled report lifecycle with valid status transitions +- store audit history for traceability +- keep reporting logic centralized in a service/facade + +The main patterns in the LLD are: + +- `Specification Pattern` for validation +- `Factory Pattern` for report creation +- `Chain of Responsibility` for moderation flow +- `Facade` through `ReportService` + +## 2. What Already Exists in This Project + +This project is already a Spring Boot + MongoDB backend with a layered structure: + +- controllers +- services +- repositories +- Mongo entities/documents +- centralized exception handling +- JWT-based authentication + +Existing related pieces already present in the codebase: + +- `InscriptionPost` already contains embedded report metadata +- `PublicPostDescription` already contains embedded report metadata +- `User` already contains `reportCount` and `blackListed` +- there is already a content moderation module for post/comment text moderation +- there is already archive/delete support for posts and comments + +Important current files: + +- `src/main/java/com/cadac/stone_inscription/entity/InscriptionPost.java` +- `src/main/java/com/cadac/stone_inscription/entity/PublicPostDescription.java` +- `src/main/java/com/cadac/stone_inscription/entity/User.java` +- `src/main/java/com/cadac/stone_inscription/entity/model/Report.java` +- `src/main/java/com/cadac/stone_inscription/entity/model/ReportEntry.java` +- `src/main/java/com/cadac/stone_inscription/moderation/service/ContentModerationService.java` + +## 3. Gap Between the LLD and the Current Project + +The project already has some report-related fields, but not a proper report module yet. + +What exists now: + +- embedded `report` object on posts/comments +- basic reporter list structure through `ReportEntry` +- per-user `reportCount` +- content moderation for creation of posts/comments + +What is missing compared to the LLD: + +- a dedicated `Report` document/collection for each report case +- report APIs: + - `POST /report` + - `GET /reports` + - `POST /moderate/:id` +- a `ReportService` facade for orchestration +- a `ReportFactory` +- specification-based validation classes +- a proper moderation pipeline for reports +- a status model matching the reporting workflow +- human moderation flow +- audit log persistence for report actions +- repository support for querying reports by state/target/reporter + +## 4. Integration Strategy + +We should adapt the LLD into the current codebase, not replace existing post/comment/user modules. + +The best integration approach is: + +- keep the current `InscriptionPost`, `PublicPostDescription`, and `User` documents as the primary content models +- introduce a new dedicated report module for report tickets +- reuse existing entities as report targets through a Spring-friendly `Reportable` abstraction or resolver +- keep existing embedded `report` metadata only as supporting summary data if needed +- use the new report collection as the source of truth for moderation workflow + +This means the system will have: + +- existing content models unchanged as the main domain objects +- a new report document to track each report independently +- synchronization logic to update content/user summary fields after moderation decisions + +## 5. How the LLD Maps to This Project + +### 5.1 Reportable + +LLD idea: + +- `Post`, `Comment`, and `User` implement `Reportable` + +Project adaptation: + +- we do not need to heavily modify every existing entity with business logic +- instead, we can use one of these two approaches: + +Option A: + +- make `InscriptionPost`, `PublicPostDescription`, and `User` implement a simple `Reportable` interface + +Option B: + +- create adapters/resolvers that expose: + - target id + - author id + - target type + +Recommended: + +- Option B if we want minimal changes to existing domain classes +- Option A if the user wants a more explicit object-oriented mapping + +For this codebase, minimal invasive integration is safer, so adapter/resolver style is likely the better fit. + +### 5.2 Report Entity + +LLD idea: + +- one report object per user-submitted report +- contains target, reporter, reason, lifecycle state, audit log, AI score, action taken + +Project adaptation: + +- create a new Mongo document such as `moderation/report/entity/ModerationReport.java` +- keep the existing embedded `entity.model.Report` only if needed for summary counters/history inside content documents + +Why this is needed: + +- the current embedded `Report` model is only a lightweight counter + reporter list +- it cannot represent full lifecycle states, moderation decisions, or an audit trail cleanly + +### 5.3 Validation Specifications + +LLD idea: + +- `NotSelfReportSpec` +- `NotDuplicateReportSpec` +- `ReporterNotBannedSpec` +- moderator-related constraints + +Project adaptation: + +- create specification classes under a dedicated report validation package +- use them inside `ReportService` +- translate failures into `StoneInscriptionException` or a dedicated reporting exception mapped by `ExceptionController` + +Initial validations should include: + +- reporter must exist +- target must exist +- reporter cannot report own content/user profile +- duplicate open report should be blocked +- banned/blacklisted reporter cannot file report +- reason must be valid +- details length limits should be enforced + +### 5.4 Factory + +LLD idea: + +- `ReportFactory` centralizes report construction + +Project adaptation: + +- create a Spring component/factory that builds the report document with: + - target type + - target id + - target author id + - reporter id + - reason + - details + - initial state + - timestamps + - audit entry + +This is a good fit and should be implemented directly. + +### 5.5 Moderation Pipeline + +LLD idea: + +- AI handler runs first +- escalates to human moderator when confidence is low +- human moderator finalizes the report + +Project adaptation: + +- create a dedicated report moderation chain, separate from content creation moderation +- do not mix it directly into `ContentModerationService` +- optionally reuse some scoring ideas or helper logic from existing moderation services + +Pipeline stages should become: + +- `PENDING` +- `AI_SCREENING` +- `ESCALATED` +- `RESOLVED` + +If you want closer alignment with the LLD, we can internally keep more detailed terminal decisions, but your requested lifecycle can still remain: + +- `PENDING -> AI_SCREENING -> RESOLVED / ESCALATED` + +Then human resolution can move: + +- `ESCALATED -> RESOLVED` + +### 5.6 Human Moderation + +LLD idea: + +- a human moderator takes final action for escalated reports + +Project adaptation: + +- expose an endpoint to resolve escalated reports manually +- require authenticated moderator/admin access +- support actions such as: + - dismiss + - remove content + - ban author + - warn + +For the current project, we need to verify how moderator/admin roles are represented in JWT authorities before wiring authorization rules. + +### 5.7 Audit Logging + +LLD idea: + +- every report state transition writes to audit log + +Project adaptation: + +- store audit entries inside the new report document +- each entry should contain: + - action + - actor + - timestamp + - optional note + +This is better than relying only on application logs, because report history must remain queryable. + +## 6. Proposed Module Structure + +To keep the codebase clean and aligned with the current style, I would introduce a new module like this: + +```text +src/main/java/com/cadac/stone_inscription/report/ + controller/ + service/ + repository/ + dto/ + entity/ + enums/ + factory/ + moderation/ + specification/ + resolver/ +``` + +Likely contents: + +- `controller/ReportController.java` +- `service/ReportService.java` +- `repository/ModerationReportRepository.java` +- `dto/CreateReportRequest.java` +- `dto/ModerateReportRequest.java` +- `dto/ReportResponse.java` +- `entity/ModerationReport.java` +- `entity/ReportAuditEntry.java` +- `enums/ReportStatus.java` +- `enums/ReportTargetType.java` +- `enums/ReportReason.java` +- `enums/ModerationAction.java` +- `factory/ReportFactory.java` +- `moderation/ModerationHandler.java` +- `moderation/AiModerationHandler.java` +- `moderation/HumanModerationHandler.java` +- `specification/Specification.java` +- `specification/NotSelfReportSpecification.java` +- `specification/NotDuplicateReportSpecification.java` +- `resolver/ReportTargetResolver.java` + +## 7. How It Will Use Existing Modules + +### Existing Post Module + +Used for: + +- resolving reported post targets +- removing/rejecting content when moderation decides so +- possibly moving removed posts to archive through existing delete/archive services + +### Existing Comment Module + +Used for: + +- resolving reported comment targets +- removing comments when required +- reusing archive/delete support already present in content delete services + +### Existing User Module + +Used for: + +- resolving reported users +- reading reporter/author information +- updating `reportCount` +- updating `blackListed` if moderation thresholds are hit + +### Existing Moderation Module + +Used for: + +- possibly reusing utility ideas for scoring or threshold-based decisioning + +Not used directly for: + +- content reporting workflow state management + +Reason: + +- current moderation service is designed for content creation screening, not user-submitted reporting workflows + +## 8. API Plan + +The requested API set can be added as a new controller. + +### `POST /report` + +Purpose: + +- create a new report ticket for `POST`, `COMMENT`, or `USER` + +Request likely includes: + +- `targetType` +- `targetId` +- `reason` +- `details` + +Behavior: + +- extract reporter from JWT +- resolve target +- run validation specs +- create report via factory +- save report +- optionally leave it in `PENDING` or trigger AI moderation immediately depending on your preferred flow + +### `GET /reports` + +Purpose: + +- list reports + +Behavior: + +- likely moderator/admin only +- support optional filtering: + - by status + - by target type + - by reporter + +### `POST /moderate/{id}` + +Purpose: + +- trigger or continue moderation + +Possible behavior: + +- if report is `PENDING`, run AI screening +- if report is `ESCALATED`, allow human moderator decision through request body + +Because your requirement includes both AI and human moderation, this endpoint may either: + +- act as a trigger endpoint for AI/human depending on state + +or + +- be split later into: + - trigger moderation + - resolve escalated report + +I would keep your requested endpoint first and implement state-aware behavior inside the service. + +## 9. Report Status Plan + +Your requested status lifecycle is: + +- `PENDING` +- `AI_SCREENING` +- `RESOLVED` +- `ESCALATED` + +Recommended final state model for this project: + +- `PENDING` +- `AI_SCREENING` +- `ESCALATED` +- `RESOLVED` +- optional `DISMISSED` + +Why include `DISMISSED`: + +- it is useful when a report is reviewed and found invalid +- otherwise `RESOLVED` becomes too broad + +If you want strict adherence to your simplified lifecycle, we can keep only: + +- `PENDING` +- `AI_SCREENING` +- `ESCALATED` +- `RESOLVED` + +and encode the final action separately. + +## 10. Data Model Recommendation + +Because this backend uses MongoDB, the best fit is a dedicated collection for reports. + +Recommended report document fields: + +- `_id` +- `reporterId` +- `targetId` +- `targetType` +- `targetAuthorId` +- `reason` +- `details` +- `status` +- `actionTaken` +- `aiConfidenceScore` +- `createdAt` +- `updatedAt` +- `resolvedAt` +- `resolvedBy` +- `auditEntries` + +Recommended indexes: + +- `status` +- `targetType + targetId` +- `reporterId` +- `createdAt` +- optional uniqueness/index rule to prevent duplicate active reports from same reporter for same target + +## 11. Minimal Working AI Moderation Logic + +The first version should stay intentionally simple. + +Suggested approach: + +- assign base score by reason +- boost score if report details or content contains flagged keywords +- if score >= threshold: + - auto-resolve with action +- else: + - escalate + +This matches the LLD and is enough for a first production-safe version if we keep actions conservative. + +Safer initial auto-actions: + +- for very clear spam/explicit cases: + - mark resolved + - optionally soft-remove content +- for uncertain cases: + - escalate + +## 12. Implementation Plan I Would Follow + +This is the plan I would execute when you allow implementation. + +### Phase 1: Design the new report module + +- create report enums, DTOs, document models, and repository +- define report target types and status model +- add audit entry model + +### Phase 2: Add target resolution + +- create a resolver that can fetch: + - `InscriptionPost` + - `PublicPostDescription` + - `User` +- expose a uniform report-target view for validation and moderation + +### Phase 3: Add validation layer + +- create specification interfaces and concrete validation rules +- centralize validation inside `ReportService` + +### Phase 4: Add report factory + +- build report creation through factory +- initialize first audit log entry and default status + +### Phase 5: Add moderation pipeline + +- implement AI moderation handler +- implement human moderation handler +- wire them using chain-of-responsibility style + +### Phase 6: Add controller endpoints + +- `POST /report` +- `GET /reports` +- `POST /moderate/{id}` + +### Phase 7: Integrate with existing content/user modules + +- apply moderation action to post/comment/user +- update existing summary report fields if still required +- increment user report counters where appropriate + +### Phase 8: Add error handling and audit safety + +- ensure invalid transitions are blocked +- ensure target-not-found cases are handled cleanly +- return consistent error messages + +### Phase 9: Verify with tests + +- create basic service/unit tests for: + - duplicate prevention + - self-report prevention + - AI auto-resolution + - escalation path + - invalid state transitions + +## 13. What I Would Not Do + +To avoid unnecessary churn, I would not: + +- rewrite existing post/comment/user modules +- remove current moderation features +- force every existing entity into a heavy inheritance model +- replace existing response/error style unless necessary +- overengineer the first AI moderator + +## 14. Final Recommendation + +The LLD is good as a behavioral blueprint, but it should be translated into Spring Boot + Mongo idioms instead of copied literally. + +Recommended implementation direction: + +- create a new report module +- keep existing content/user modules intact +- use a dedicated Mongo report collection as the source of truth +- reuse existing moderation and archive/delete modules where they already fit +- preserve the LLD patterns in Spring-friendly form: + - specifications for validation + - factory for report creation + - service facade for orchestration + - chain of responsibility for moderation + +This gives us a clean implementation that matches your LLD while staying natural to the current codebase. diff --git a/ReportSystem.java b/ReportSystem.java new file mode 100644 index 0000000..1d79920 --- /dev/null +++ b/ReportSystem.java @@ -0,0 +1,965 @@ +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +// ───────────────────────────────────────────── +// ENUMS +// ───────────────────────────────────────────── + +enum Role { USER, HUMAN_MODERATOR, AI_MODERATOR, ADMIN } + +enum ReportStatus { PENDING, AI_SCREENING, ESCALATED_TO_HUMAN, RESOLVED_AUTO, RESOLVED_HUMAN, DISMISSED } + +enum ReportReason { SPAM, HATE_SPEECH, MISINFORMATION, HARASSMENT, EXPLICIT_CONTENT, OTHER } + +enum ModerationAction { NONE, WARN, REMOVE_CONTENT, BAN_REPORTER, BAN_AUTHOR, ESCALATE, DISMISS } + + +// ───────────────────────────────────────────── +// EXCEPTIONS +// ───────────────────────────────────────────── + +class ReportValidationException extends RuntimeException { + ReportValidationException(String msg) { super(msg); } +} + +class ModerationException extends RuntimeException { + ModerationException(String msg) { super(msg); } +} + + +// ───────────────────────────────────────────── +// MARKER INTERFACE +// ───────────────────────────────────────────── + +interface Reportable { + String getId(); + String getAuthorId(); + String getContentType(); // "POST" | "COMMENT" | "USER" +} + + +// ───────────────────────────────────────────── +// ENTITIES +// ───────────────────────────────────────────── + +class User implements Reportable { + private final String id; + private final String name; + private Role role; + private boolean banned; + private int reportCount; // times this user has been reported + private int reportsFiledCount; // times this user has filed reports + + User(String id, String name, Role role) { + this.id = id; this.name = name; this.role = role; + this.banned = false; + } + + @Override public String getId() { return id; } + @Override public String getAuthorId() { return id; } + @Override public String getContentType() { return "USER"; } + + public String getName() { return name; } + public Role getRole() { return role; } + public boolean isBanned() { return banned; } + public int getReportCount() { return reportCount; } + public int getReportsFiledCount() { return reportsFiledCount; } + + public void setBanned(boolean banned) { this.banned = banned; } + public void incrementReportCount() { this.reportCount++; } + public void incrementReportsFiledCount() { this.reportsFiledCount++; } + + public boolean isModerator() { + return role == Role.HUMAN_MODERATOR || role == Role.AI_MODERATOR || role == Role.ADMIN; + } + + @Override public String toString() { + return String.format("User{id='%s', name='%s', role=%s, banned=%s}", id, name, role, banned); + } +} + +class Post implements Reportable { + private final String id; + private final String authorId; + private String content; + private boolean removed; + private final Instant createdAt; + + Post(String id, String authorId, String content) { + this.id = id; this.authorId = authorId; + this.content = content; this.removed = false; + this.createdAt = Instant.now(); + } + + @Override public String getId() { return id; } + @Override public String getAuthorId() { return authorId; } + @Override public String getContentType() { return "POST"; } + + public String getContent() { return content; } + public boolean isRemoved() { return removed; } + public Instant getCreatedAt(){ return createdAt; } + public void remove() { this.removed = true; this.content = "[removed]"; } + + @Override public String toString() { + // TODO: Remove the comments on the post + // TODO: Remove the post, move the post to archive + return String.format("Post{id='%s', content='%s', removed=%s}", id, content, removed ); + } +} + +class Comment implements Reportable { + private final String id; + private final String authorId; + private final String postId; + private String description; + private boolean removed; + + Comment(String id, String authorId, String postId, String description) { + this.id = id; this.authorId = authorId; + this.postId = postId; this.description = description; + this.removed = false; + } + + @Override public String getId() { return id; } + @Override public String getAuthorId() { return authorId; } + @Override public String getContentType() { return "COMMENT"; } + + public String getDescription() { return description; } + public boolean isRemoved() { return removed; } + public void remove() { + // TODO: Move comments to the archive + this.removed = true; this.description = "[removed]"; + } + + @Override public String toString() { + return String.format("Comment{id='%s', desc='%s', removed=%s}", id, description, removed); + } +} + + +// ───────────────────────────────────────────── +// REPORT +// ───────────────────────────────────────────── + +class Report { + private final String id; + private final String reporterId; + private final String targetId; + private final String targetType; + private final ReportReason reason; + private final String details; + private ReportStatus status; + private ModerationAction actionTaken; + private String resolvedBy; // moderator id or "AI" + private final Instant createdAt; + private Instant resolvedAt; + private double aiConfidenceScore; // 0.0 – 1.0 + private final List auditLog; + + Report(String id, String reporterId, Reportable target, ReportReason reason, String details) { + this.id = id; + this.reporterId = reporterId; + this.targetId = target.getId(); + this.targetType = target.getContentType(); + this.reason = reason; + this.details = details; + this.status = ReportStatus.PENDING; + this.actionTaken = ModerationAction.NONE; + this.createdAt = Instant.now(); + this.auditLog = new ArrayList<>(); + addAuditEntry("Report created by reporter=" + reporterId); + } + + // ── Getters ── + public String getId() { return id; } + public String getReporterId() { return reporterId; } + public String getTargetId() { return targetId; } + public String getTargetType() { return targetType; } + public ReportReason getReason() { return reason; } + public String getDetails() { return details; } + public ReportStatus getStatus() { return status; } + public ModerationAction getActionTaken() { return actionTaken; } + public double getAiConfidenceScore() { return aiConfidenceScore; } + public List getAuditLog() { return Collections.unmodifiableList(auditLog); } + + // ── Transitions (enforced — no arbitrary status jumps) ── + public void transitionTo(ReportStatus newStatus, String actor, ModerationAction action) { + validateTransition(this.status, newStatus); + this.status = newStatus; + this.actionTaken = action; + this.resolvedBy = actor; + if (isTerminal(newStatus)) this.resolvedAt = Instant.now(); + addAuditEntry(String.format("Status → %s | Action → %s | By → %s", newStatus, action, actor)); + } + + public void setAiConfidenceScore(double score) { + this.aiConfidenceScore = score; + addAuditEntry(String.format("AI confidence score set: %.2f", score)); + } + + private void validateTransition(ReportStatus from, ReportStatus to) { + Map> allowed = new HashMap<>(); + allowed.put(ReportStatus.PENDING, new HashSet<>(Arrays.asList(ReportStatus.AI_SCREENING, ReportStatus.DISMISSED))); + allowed.put(ReportStatus.AI_SCREENING, new HashSet<>(Arrays.asList(ReportStatus.RESOLVED_AUTO, ReportStatus.ESCALATED_TO_HUMAN))); + allowed.put(ReportStatus.ESCALATED_TO_HUMAN, new HashSet<>(Arrays.asList(ReportStatus.RESOLVED_HUMAN, ReportStatus.DISMISSED))); + + Set validNext = allowed.getOrDefault(from, Collections.emptySet()); + if (!validNext.contains(to)) { + throw new ModerationException( + String.format("Invalid transition: %s → %s", from, to)); + } + } + + private boolean isTerminal(ReportStatus s) { + return s == ReportStatus.RESOLVED_AUTO + || s == ReportStatus.RESOLVED_HUMAN + || s == ReportStatus.DISMISSED; + } + + private void addAuditEntry(String entry) { + auditLog.add(String.format("[%s] %s", Instant.now(), entry)); + } + + @Override public String toString() { + return String.format( + "Report{id='%s', target=%s(%s), reason=%s, status=%s, action=%s, aiScore=%.2f}", + id, targetType, targetId, reason, status, actionTaken, aiConfidenceScore); + } +} + + +// ───────────────────────────────────────────── +// REPOSITORIES +// ───────────────────────────────────────────── + +interface ReportRepository { + Report save(Report report); + Optional findById(String id); + List findByStatus(ReportStatus status); + boolean existsByReporterAndTarget(String reporterId, String targetId); + List findAll(); +} + +class InMemoryReportRepository implements ReportRepository { + private final Map store = new LinkedHashMap<>(); + + @Override public Report save(Report r) { store.put(r.getId(), r); return r; } + + @Override public Optional findById(String id) { + return Optional.ofNullable(store.get(id)); + } + + @Override public List findByStatus(ReportStatus status) { + return store.values().stream() + .filter(r -> r.getStatus() == status) + .collect(Collectors.toList()); + } + + @Override public boolean existsByReporterAndTarget(String reporterId, String targetId) { + return store.values().stream() + .anyMatch(r -> r.getReporterId().equals(reporterId) + && r.getTargetId().equals(targetId)); + } + + @Override public List findAll() { return new ArrayList<>(store.values()); } +} + +interface UserRepository { + User save(User user); + Optional findById(String id); + List findAll(); +} + +class InMemoryUserRepository implements UserRepository { + private final Map store = new LinkedHashMap<>(); + + @Override public User save(User u) { store.put(u.getId(), u); return u; } + @Override public Optional findById(String id) { return Optional.ofNullable(store.get(id)); } + @Override public List findAll() { return new ArrayList<>(store.values()); } +} + +interface ContentRepository { + void savePost(Post post); + void saveComment(Comment comment); + Optional findPostById(String id); + Optional findCommentById(String id); +} + +class InMemoryContentRepository implements ContentRepository { + private final Map posts = new LinkedHashMap<>(); + private final Map comments = new LinkedHashMap<>(); + + @Override public void savePost(Post p) { posts.put(p.getId(), p); } + @Override public void saveComment(Comment c) { comments.put(c.getId(), c); } + @Override public Optional findPostById(String id) { return Optional.ofNullable(posts.get(id)); } + @Override public Optional findCommentById(String id) { return Optional.ofNullable(comments.get(id)); } +} + + +// ───────────────────────────────────────────── +// SPECIFICATION PATTERN (validation rules) +// ───────────────────────────────────────────── + +interface Specification { + boolean isSatisfiedBy(T candidate); + String errorMessage(); + + default Specification and(Specification other) { + return new AndSpecification<>(this, other); + } +} + +class AndSpecification implements Specification { + private final Specification left; + private final Specification right; + + AndSpecification(Specification left, Specification right) { + this.left = left; this.right = right; + } + + @Override public boolean isSatisfiedBy(T t) { + return left.isSatisfiedBy(t) && right.isSatisfiedBy(t); + } + + @Override public String errorMessage() { + return left.errorMessage() + " | " + right.errorMessage(); + } +} + +// Payload carrying everything a spec might need to check +class ReportRequest { + final User reporter; + final Reportable target; + final ReportRepository reportRepo; + + ReportRequest(User reporter, Reportable target, ReportRepository reportRepo) { + this.reporter = reporter; this.target = target; this.reportRepo = reportRepo; + } +} + +class NotSelfReportSpec implements Specification { + @Override public boolean isSatisfiedBy(ReportRequest r) { + return !r.reporter.getId().equals(r.target.getAuthorId()); + } + @Override public String errorMessage() { return "A user cannot report their own content."; } +} + +class NotDuplicateReportSpec implements Specification { + @Override public boolean isSatisfiedBy(ReportRequest r) { + return !r.reportRepo.existsByReporterAndTarget(r.reporter.getId(), r.target.getId()); + } + @Override public String errorMessage() { return "You have already reported this content."; } +} + +class ReporterNotBannedSpec implements Specification { + @Override public boolean isSatisfiedBy(ReportRequest r) { return !r.reporter.isBanned(); } + @Override public String errorMessage() { return "Banned users cannot file reports."; } +} + +class ModeratorCannotModerateOwnSpec implements Specification { + @Override public boolean isSatisfiedBy(ReportRequest r) { + // applies when the reporter is actually a moderator reviewing their own content + if (r.reporter.isModerator()) { + return !r.reporter.getId().equals(r.target.getAuthorId()); + } + return true; + } + @Override public String errorMessage() { return "A moderator cannot moderate their own content."; } +} + + +// ───────────────────────────────────────────── +// FACTORY +// ───────────────────────────────────────────── + +class ReportFactory { + private int counter = 1; + + public Report create(User reporter, Reportable target, ReportReason reason, String details) { + String id = String.format("RPT-%04d", counter++); + return new Report(id, reporter.getId(), target, reason, details); + } +} + + +// ───────────────────────────────────────────── +// MODERATOR INTERFACE + IMPLEMENTATIONS +// ───────────────────────────────────────────── + +interface Moderator { + ModerationAction screen(Report report, Reportable target, UserRepository userRepo, ContentRepository contentRepo); + String getModeratorId(); +} + +// ── AI Moderator — scores and auto-resolves high-confidence cases ── +class AIModerator implements Moderator { + private static final String ID = "AI_MOD"; + private static final double AUTO_RESOLVE_THRESHOLD = 0.85; + + @Override + public ModerationAction screen(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo) { + double score = computeConfidenceScore(report, target); + report.setAiConfidenceScore(score); + + if (score >= AUTO_RESOLVE_THRESHOLD) { + return applyAction(report, target, userRepo, contentRepo); + } + // Not confident enough — escalate + return ModerationAction.ESCALATE; + } + + private double computeConfidenceScore(Report report, Reportable target) { + // Simulated scoring based on reason severity + keyword detection + double base = switch (report.getReason()) { + case HATE_SPEECH -> 0.80; + case EXPLICIT_CONTENT -> 0.75; + case HARASSMENT -> 0.70; + case SPAM -> 0.60; + case MISINFORMATION -> 0.50; + case OTHER -> 0.30; + }; + + // Boost if content contains flagged keywords (simplified simulation) + String content = getContent(target); + if (content != null && containsFlaggedKeywords(content)) base += 0.15; + + return Math.min(base, 1.0); + } + + private boolean containsFlaggedKeywords(String content) { + List flagged = Arrays.asList("hate", "spam", "fake", "scam", "explicit", "kill"); + String lower = content.toLowerCase(); + return flagged.stream().anyMatch(lower::contains); + } + + private String getContent(Reportable target) { + if (target instanceof Post) return ((Post) target).getContent(); + if (target instanceof Comment) return ((Comment) target).getDescription(); + return null; + } + + private ModerationAction applyAction(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo) { + // Remove content + if (target instanceof Post) ((Post) target).remove(); + if (target instanceof Comment) ((Comment) target).remove(); + + // If author has 3+ reports, auto-ban + userRepo.findById(target.getAuthorId()).ifPresent(author -> { + author.incrementReportCount(); + if (author.getReportCount() >= 3) { + author.setBanned(true); + } + }); + + return ModerationAction.REMOVE_CONTENT; + } + + @Override public String getModeratorId() { return ID; } +} + +// ── Admin Moderator — handles escalated cases ── +class Admin implements Moderator { + private final User moderatorUser; + + Admin(User moderatorUser) { + if (!moderatorUser.isModerator()) { + throw new ModerationException("User " + moderatorUser.getId() + " is not a moderator."); + } + this.moderatorUser = moderatorUser; + } + + @Override + public ModerationAction screen(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo) { + // Simulated human decision based on report reason + AI score + ModerationAction decision = decideAction(report, target); + + switch (decision) { + case REMOVE_CONTENT -> { + if (target instanceof Post) ((Post) target).remove(); + if (target instanceof Comment) ((Comment) target).remove(); + userRepo.findById(target.getAuthorId()).ifPresent(User::incrementReportCount); + } + case BAN_AUTHOR -> { + if (target instanceof Post) ((Post) target).remove(); + if (target instanceof Comment) ((Comment) target).remove(); + userRepo.findById(target.getAuthorId()).ifPresent(a -> { + a.setBanned(true); + a.incrementReportCount(); + }); + } + case BAN_REPORTER -> { + userRepo.findById(report.getReporterId()).ifPresent(r -> r.setBanned(true)); + } + case DISMISS -> { /* no action on content */ } + default -> { } + } + + return decision; + } + + private ModerationAction decideAction(Report report, Reportable target) { + // Simulate human judgment: if AI was already fairly confident, remove content + if (report.getAiConfidenceScore() >= 0.60) { + return switch (report.getReason()) { + case HATE_SPEECH, HARASSMENT -> ModerationAction.BAN_AUTHOR; + case SPAM, EXPLICIT_CONTENT -> ModerationAction.REMOVE_CONTENT; + default -> ModerationAction.REMOVE_CONTENT; + }; + } + // Low-confidence + escalated → dismiss (reporter was wrong) + return ModerationAction.DISMISS; + } + + @Override public String getModeratorId() { return moderatorUser.getId(); } +} + + +// ───────────────────────────────────────────── +// CHAIN OF RESPONSIBILITY (moderation pipeline) +// ───────────────────────────────────────────── + +abstract class ModerationHandler { + protected ModerationHandler next; + + public ModerationHandler setNext(ModerationHandler next) { + this.next = next; + return next; // fluent chaining + } + + public abstract void handle(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo, + ReportRepository reportRepo); + + protected void passToNext(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo, + ReportRepository reportRepo) { + if (next != null) { + next.handle(report, target, userRepo, contentRepo, reportRepo); + } else { + System.out.println(" [Chain] No further handler. Report left in current state: " + report.getStatus()); + } + } +} + +class AIScreeningHandler extends ModerationHandler { + private final AIModerator aiModerator; + + AIScreeningHandler(AIModerator aiModerator) { this.aiModerator = aiModerator; } + + @Override + public void handle(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo, + ReportRepository reportRepo) { + System.out.println(" [AI Handler] Screening report " + report.getId() + "..."); + + report.transitionTo(ReportStatus.AI_SCREENING, aiModerator.getModeratorId(), ModerationAction.NONE); + ModerationAction action = aiModerator.screen(report, target, userRepo, contentRepo); + + System.out.printf(" [AI Handler] Score=%.2f | Decision=%s%n", + report.getAiConfidenceScore(), action); + + if (action == ModerationAction.ESCALATE) { + System.out.println(" [AI Handler] Confidence too low. Escalating to human moderator..."); + report.transitionTo(ReportStatus.ESCALATED_TO_HUMAN, aiModerator.getModeratorId(), ModerationAction.ESCALATE); + reportRepo.save(report); + passToNext(report, target, userRepo, contentRepo, reportRepo); + } else { + report.transitionTo(ReportStatus.RESOLVED_AUTO, aiModerator.getModeratorId(), action); + reportRepo.save(report); + System.out.println(" [AI Handler] Auto-resolved. Action taken: " + action); + } + } +} + +class AdminModerationHandler extends ModerationHandler { + private final Admin humanModerator; + + AdminModerationHandler(Admin humanModerator) { this.humanModerator = humanModerator; } + + @Override + public void handle(Report report, Reportable target, + UserRepository userRepo, ContentRepository contentRepo, + ReportRepository reportRepo) { + System.out.println(" [Human Handler] Moderator " + humanModerator.getModeratorId() + + " reviewing escalated report " + report.getId() + "..."); + + ModerationAction action = humanModerator.screen(report, target, userRepo, contentRepo); + ReportStatus finalStatus = (action == ModerationAction.DISMISS) + ? ReportStatus.DISMISSED + : ReportStatus.RESOLVED_HUMAN; + + report.transitionTo(finalStatus, humanModerator.getModeratorId(), action); + reportRepo.save(report); + System.out.println(" [Human Handler] Decision: " + action + " → Status: " + finalStatus); + } +} + + +// ───────────────────────────────────────────── +// REPORT SERVICE (facade — orchestrates everything) +// ───────────────────────────────────────────── + +class ReportService { + private final ReportRepository reportRepo; + private final UserRepository userRepo; + private final ContentRepository contentRepo; + private final ReportFactory factory; + private final Specification validationChain; + private final ModerationHandler moderationPipeline; + + ReportService(ReportRepository reportRepo, + UserRepository userRepo, + ContentRepository contentRepo, + ReportFactory factory, + ModerationHandler moderationPipeline) { + this.reportRepo = reportRepo; + this.userRepo = userRepo; + this.contentRepo = contentRepo; + this.factory = factory; + this.moderationPipeline = moderationPipeline; + + // Compose validation rules + this.validationChain = new NotSelfReportSpec() + .and(new NotDuplicateReportSpec()) + .and(new ReporterNotBannedSpec()) + .and(new ModeratorCannotModerateOwnSpec()); + } + + /** Step 1 — user files a report */ + public Report fileReport(User reporter, Reportable target, ReportReason reason, String details) { + ReportRequest req = new ReportRequest(reporter, target, reportRepo); + + // Run all specs; collect failures + List violations = new ArrayList<>(); + for (Specification spec : allSpecs()) { + if (!spec.isSatisfiedBy(req)) violations.add(spec.errorMessage()); + } + if (!violations.isEmpty()) { + throw new ReportValidationException("Report rejected: " + String.join("; ", violations)); + } + + Report report = factory.create(reporter, target, reason, details); + reporter.incrementReportsFiledCount(); + reportRepo.save(report); + + System.out.println(" [ReportService] Report filed: " + report.getId() + + " by " + reporter.getName() + + " against " + target.getContentType() + "(" + target.getId() + ")" + + " for " + reason); + + return report; + } + + /** Step 2 — trigger moderation pipeline for a report */ + public void startModeration(Report report) { + Reportable target = resolveTarget(report); + if (target == null) { + throw new ModerationException("Target not found for report " + report.getId()); + } + System.out.println(" [ReportService] Starting moderation pipeline for " + report.getId()); + moderationPipeline.handle(report, target, userRepo, contentRepo, reportRepo); + } + + /** Convenience: file + moderate in one call */ + public Report fileAndModerate(User reporter, Reportable target, ReportReason reason, String details) { + Report report = fileReport(reporter, target, reason, details); + startModeration(report); + return report; + } + + public List getPendingReports() { return reportRepo.findByStatus(ReportStatus.PENDING); } + public List getAllReports() { return reportRepo.findAll(); } + + private Reportable resolveTarget(Report report) { + return switch (report.getTargetType()) { + case "POST" -> contentRepo.findPostById(report.getTargetId()).map(p -> (Reportable) p).orElse(null); + case "COMMENT" -> contentRepo.findCommentById(report.getTargetId()).map(c -> (Reportable) c).orElse(null); + case "USER" -> userRepo.findById(report.getTargetId()).map(u -> (Reportable) u).orElse(null); + default -> null; + }; + } + + private List> allSpecs() { + return Arrays.asList( + new NotSelfReportSpec(), + new NotDuplicateReportSpec(), + new ReporterNotBannedSpec(), + new ModeratorCannotModerateOwnSpec() + ); + } +} + + +// ───────────────────────────────────────────── +// MAIN — ENTRY POINT +// ───────────────────────────────────────────── + +public class ReportSystem { + + // ── Shared state (injected everywhere) ── + static ReportRepository reportRepo; + static UserRepository userRepo; + static ContentRepository contentRepo; + static ReportService reportService; + + // ── Dummy data handles ── + static User alice, bob, carol, adminMod; + static Post post1, post2; + static Comment comment1; + + // ───────────────────────────────────────── + // INIT + // ───────────────────────────────────────── + static void init() { + System.out.println("═══════════════════════════════════════════════════"); + System.out.println(" INITIALISING REPORT SYSTEM"); + System.out.println("═══════════════════════════════════════════════════"); + + // Repositories + reportRepo = new InMemoryReportRepository(); + userRepo = new InMemoryUserRepository(); + contentRepo = new InMemoryContentRepository(); + + // Users + alice = new User("u001", "Alice", Role.USER); + bob = new User("u002", "Bob", Role.USER); + carol = new User("u003", "Carol", Role.USER); + adminMod = new User("u099", "AdminMod",Role.HUMAN_MODERATOR); + + userRepo.save(alice); + userRepo.save(bob); + userRepo.save(carol); + userRepo.save(adminMod); + + // Posts + post1 = new Post("p001", bob.getId(), "Buy cheap followers now! Spam spam spam!"); + post2 = new Post("p002", carol.getId(), "Completely normal post about cooking."); + + contentRepo.savePost(post1); + contentRepo.savePost(post2); + + // Comments + comment1 = new Comment("c001", bob.getId(), post2.getId(), + "This is fake news, scam alert!"); + contentRepo.saveComment(comment1); + + // Moderation pipeline: AI first → human if escalated + AIModerator ai = new AIModerator(); + Admin human = new Admin(adminMod); + + AIScreeningHandler aiHandler = new AIScreeningHandler(ai); + AdminModerationHandler humanHandler = new AdminModerationHandler(human); + aiHandler.setNext(humanHandler); // Chain of Responsibility wired up + + // Service (facade) + reportService = new ReportService( + reportRepo, userRepo, contentRepo, + new ReportFactory(), + aiHandler + ); + + System.out.println(" Users created : " + userRepo.findAll().size()); + System.out.println(" Posts created : 2 (post1 by Bob, post2 by Carol)"); + System.out.println(" Comments created : 1 (comment1 by Bob on post2)"); + System.out.println(" Moderators : AdminMod (human), AI_MOD (automatic)"); + System.out.println(); + } + + + // ───────────────────────────────────────── + // SCENARIO 1 — User creates a report + // ───────────────────────────────────────── + static String userCreatesAReport() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SCENARIO 1 : Alice reports Bob's spam post"); + System.out.println("───────────────────────────────────────────────────"); + + try { + // Alice reports post1 (authored by Bob) for spam + Report report = reportService.fileReport( + alice, post1, ReportReason.SPAM, + "This post is clearly advertising spam and should be removed." + ); + + return String.format( + "[OK] Report created → id=%s | target=%s(%s) | status=%s", + report.getId(), report.getTargetType(), report.getTargetId(), report.getStatus() + ); + + } catch (ReportValidationException e) { + return "[REJECTED] " + e.getMessage(); + } + } + + // ───────────────────────────────────────── + // SCENARIO 2 — Duplicate report attempt + // ───────────────────────────────────────── + static String userTriesToReportSamePostTwice() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SCENARIO 2 : Alice tries to report Bob's post again"); + System.out.println("───────────────────────────────────────────────────"); + + try { + reportService.fileReport( + alice, post1, ReportReason.SPAM, "Reporting again." + ); + return "[OK] Second report created (unexpected!)"; + } catch (ReportValidationException e) { + return "[REJECTED] " + e.getMessage(); + } + } + + // ───────────────────────────────────────── + // SCENARIO 3 — Self-report attempt + // ───────────────────────────────────────── + static String userTriesToReportOwnContent() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SCENARIO 3 : Bob tries to report his own post"); + System.out.println("───────────────────────────────────────────────────"); + + try { + reportService.fileReport( + bob, post1, ReportReason.OTHER, "I want to report myself." + ); + return "[OK] Self-report created (unexpected!)"; + } catch (ReportValidationException e) { + return "[REJECTED] " + e.getMessage(); + } + } + + // ───────────────────────────────────────── + // SCENARIO 4 — Content moderation starts (AI auto-resolves) + // ───────────────────────────────────────── + static String contentModerationStarts() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SCENARIO 4 : Moderation pipeline runs on pending reports"); + System.out.println("───────────────────────────────────────────────────"); + + // Carol reports Bob's comment (contains flagged keywords → AI should be confident) + Report commentReport = reportService.fileReport( + carol, comment1, ReportReason.MISINFORMATION, + "This comment spreads fake information." + ); + + System.out.println(" Running pipeline on comment report..."); + reportService.startModeration(commentReport); + + // Check outcome + String commentStatus = String.format( + "Comment report %s → status=%s | action=%s | removed=%s", + commentReport.getId(), commentReport.getStatus(), + commentReport.getActionTaken(), comment1.isRemoved() + ); + + // Also moderate the spam post filed by Alice in scenario 1 + List pending = reportRepo.findAll().stream() + .filter(r -> r.getStatus() == ReportStatus.PENDING) + .collect(Collectors.toList()); + + StringBuilder sb = new StringBuilder(commentStatus); + for (Report r : pending) { + System.out.println("\n Running pipeline on report " + r.getId() + "..."); + reportService.startModeration(r); + sb.append(String.format( + "\n Post report %s → status=%s | action=%s | post1 removed=%s", + r.getId(), r.getStatus(), r.getActionTaken(), post1.isRemoved() + )); + } + + return sb.toString(); + } + + // ───────────────────────────────────────── + // SCENARIO 5 — Escalated report (human moderator decides) + // ───────────────────────────────────────── + static String escalatedReportHandledByHuman() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SCENARIO 5 : Carol's post escalated to human moderator"); + System.out.println("───────────────────────────────────────────────────"); + + // Alice reports Carol's benign post with a vague reason + // AI score will be low → escalates to human → human dismisses + Report report = reportService.fileReport( + alice, post2, ReportReason.OTHER, + "I just don't like this post." + ); + + System.out.println(" Running pipeline on low-confidence report..."); + reportService.startModeration(report); + + return String.format( + "[OK] Report %s → status=%s | action=%s | post2 removed=%s | carol banned=%s", + report.getId(), report.getStatus(), report.getActionTaken(), + post2.isRemoved(), carol.isBanned() + ); + } + + // ───────────────────────────────────────── + // SCENARIO 6 — Final system summary + // ───────────────────────────────────────── + static String systemSummary() { + System.out.println("───────────────────────────────────────────────────"); + System.out.println(" SYSTEM SUMMARY"); + System.out.println("───────────────────────────────────────────────────"); + + StringBuilder sb = new StringBuilder(); + List all = reportService.getAllReports(); + + sb.append(String.format("Total reports : %d%n", all.size())); + for (Report r : all) { + sb.append(" ").append(r).append("\n"); + } + + sb.append(String.format("%nUser states:%n")); + for (User u : userRepo.findAll()) { + sb.append(String.format(" %-12s | role=%-16s | banned=%-5s | timesReported=%d%n", + u.getName(), u.getRole(), u.isBanned(), u.getReportCount())); + } + + sb.append(String.format("%nContent states:%n")); + sb.append(String.format(" post1 (Bob's spam post) removed=%s%n", post1.isRemoved())); + sb.append(String.format(" post2 (Carol's cooking post) removed=%s%n", post2.isRemoved())); + sb.append(String.format(" comment1 (Bob's fake-news comment) removed=%s%n", comment1.isRemoved())); + + return sb.toString(); + } + + + // ───────────────────────────────────────── + // MAIN + // ───────────────────────────────────────── + public static void main(String[] args) { + + init(); + + System.out.println(userCreatesAReport()); + System.out.println(); + + System.out.println(userTriesToReportSamePostTwice()); + System.out.println(); + + System.out.println(userTriesToReportOwnContent()); + System.out.println(); + + System.out.println(contentModerationStarts()); + System.out.println(); + + System.out.println(escalatedReportHandledByHuman()); + System.out.println(); + + System.out.println(systemSummary()); + } +} \ No newline at end of file diff --git a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java index 0d37a47..ef985d0 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java @@ -1,6 +1,7 @@ package com.cadac.stone_inscription.moderation.dto; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -19,18 +20,23 @@ public class ContentModerationResponseDto { private String timestamp; @JsonProperty("decision") + @JsonAlias({ "verdict", "action" }) private String decision; @JsonProperty("label") + @JsonAlias({ "category", "classification" }) private String label; @JsonProperty("confidence") + @JsonAlias({ "score", "confidenceScore", "confidence_score", "probability" }) private Double confidence; @JsonProperty("reason") + @JsonAlias({ "message", "explanation" }) private String reason; @JsonProperty("status") + @JsonAlias({ "state" }) private String status; @JsonProperty("description") diff --git a/src/main/java/com/cadac/stone_inscription/moderation/service/ContentModerationService.java b/src/main/java/com/cadac/stone_inscription/moderation/service/ContentModerationService.java index 49be6b6..5416f01 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/service/ContentModerationService.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/service/ContentModerationService.java @@ -1,8 +1,10 @@ package com.cadac.stone_inscription.moderation.service; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,7 +19,6 @@ import com.cadac.stone_inscription.moderation.dto.ContentModerationRequestDto; import com.cadac.stone_inscription.moderation.dto.ContentModerationResponseDto; import com.cadac.stone_inscription.moderation.model.ContentModerationResult; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,6 +27,17 @@ public class ContentModerationService { private static final Logger log = LoggerFactory.getLogger(ContentModerationService.class); + private static final List MODERATION_WRAPPER_FIELDS = List.of( + "data", + "result", + "results", + "response", + "responses", + "body", + "payload", + "output", + "outputs", + "json"); private final N8nModerationClient n8nModerationClient; private final ContentModerationProperties properties; @@ -75,25 +87,31 @@ public ContentModerationResult moderate(String title, String topic, String descr ContentModerationResponseDto moderationResponse = response.get(0); Double confidence = moderationResponse.getConfidence() == null ? 0.0 : moderationResponse.getConfidence(); String decision = normalize(moderationResponse.getDecision()); - boolean approved = "ALLOW".equals(decision) && confidence >= properties.getSafeThreshold(); + String status = normalize(moderationResponse.getStatus()); + boolean approved = isApproved(decision, status, confidence); return ContentModerationResult.builder() .approved(approved) .label(normalize(moderationResponse.getLabel())) .confidence(confidence) .decision(decision) - .status(normalize(moderationResponse.getStatus())) + .status(status) .reason(cleanReason(moderationResponse.getReason())) .build(); } public String buildRejectionMessage(ContentModerationResult moderationResult) { String reason = moderationResult.getReason(); - if (reason == null || reason.isBlank()) { - return "Content failed moderation and was not saved."; + if (reason != null && !reason.isBlank()) { + return "Content failed moderation and was not saved: " + reason; } - return "Content failed moderation and was not saved: " + reason; + return String.format( + "Content failed moderation and was not saved. decision=%s, status=%s, confidence=%s, threshold=%s", + fallbackValue(moderationResult.getDecision()), + fallbackValue(moderationResult.getStatus()), + moderationResult.getConfidence() == null ? "null" : moderationResult.getConfidence(), + properties.getSafeThreshold()); } private void validateRequest(String title, String topic, String description) { @@ -126,6 +144,29 @@ private String cleanReason(String reason) { return reason.trim(); } + private boolean isApproved(String decision, String status, Double confidence) { + double score = confidence == null ? 0.0 : confidence; + boolean rejectedDecision = "BLOCK".equals(decision) || "REJECT".equals(decision) || "DENY".equals(decision); + boolean rejectedStatus = "REJECTED".equals(status) || "BLOCKED".equals(status) || "DENIED".equals(status); + if (rejectedDecision || rejectedStatus) { + return false; + } + + boolean pendingReview = "REVIEW".equals(decision) || "PENDING_REVIEW".equals(status) + || "UNDER_REVIEW".equals(status); + if (pendingReview) { + return true; + } + + boolean acceptedDecision = "ALLOW".equals(decision) || "APPROVED".equals(decision); + boolean acceptedStatus = "APPROVED".equals(status) || "ALLOW".equals(status); + return score >= properties.getSafeThreshold() && (acceptedDecision || acceptedStatus); + } + + private String fallbackValue(String value) { + return value == null || value.isBlank() ? "null" : value; + } + private List parseResponse(String rawResponse) { if (rawResponse == null || rawResponse.isBlank()) { log.error("Content moderation webhook returned blank response"); @@ -138,13 +179,9 @@ private List parseResponse(String rawResponse) { JsonNode root = objectMapper.readTree(rawResponse); log.info("Content moderation raw response: {}", rawResponse); - if (root.isArray()) { - return objectMapper.readValue(rawResponse, new TypeReference>() { - }); - } - - if (root.isObject()) { - return List.of(objectMapper.treeToValue(root, ContentModerationResponseDto.class)); + List extractedResponses = extractModerationResponses(root); + if (!extractedResponses.isEmpty()) { + return extractedResponses; } } catch (IOException ex) { log.error("Failed to parse content moderation response body={}", rawResponse, ex); @@ -159,6 +196,85 @@ private List parseResponse(String rawResponse) { HttpStatus.SERVICE_UNAVAILABLE); } + private List extractModerationResponses(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) { + return List.of(); + } + + if (node.isArray()) { + List responses = new ArrayList<>(); + for (JsonNode child : node) { + responses.addAll(extractModerationResponses(child)); + } + return responses; + } + + if (!node.isObject()) { + return List.of(); + } + + List wrappedResponses = extractWrappedResponses(node); + if (!wrappedResponses.isEmpty()) { + return wrappedResponses; + } + + if (looksLikeModerationNode(node)) { + return List.of(objectMapper.convertValue(node, ContentModerationResponseDto.class)); + } + + return List.of(); + } + + private List extractWrappedResponses(JsonNode node) { + List responses = new ArrayList<>(); + + for (String field : MODERATION_WRAPPER_FIELDS) { + JsonNode wrappedNode = node.get(field); + if (wrappedNode != null) { + responses.addAll(extractModerationResponses(wrappedNode)); + } + } + + if (!responses.isEmpty()) { + return responses; + } + + for (Map.Entry entry : iterable(node.fields())) { + if (MODERATION_WRAPPER_FIELDS.contains(entry.getKey())) { + continue; + } + + responses.addAll(extractModerationResponses(entry.getValue())); + if (!responses.isEmpty()) { + return responses; + } + } + + return responses; + } + + private boolean looksLikeModerationNode(JsonNode node) { + return hasAny(node, + "decision", "verdict", "action", + "status", "state", + "confidence", "score", "confidenceScore", "confidence_score", "probability", + "label", "category", "classification"); + } + + private boolean hasAny(JsonNode node, String... fields) { + for (String field : fields) { + if (node.has(field) && !node.get(field).isNull()) { + return true; + } + } + + return false; + } + + private Iterable iterable(java.util.Iterator iterator) { + return () -> iterator; + } + private String safeErrorMessage(String message) { if (message == null || message.isBlank()) { return "unknown error"; diff --git a/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java b/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java index 30a21b2..fa1a245 100644 --- a/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java +++ b/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java @@ -295,6 +295,16 @@ public ResponseEntity deleteImagesFromPost(HttpServletRequest request, // return postService.addPostWithFile(InscriptionPostDto, files, email); // } + // @PostMapping("/test/addPoastDiscription/{email}") + // public ResponseEntity addPoastDiscriptionForTest( + // @PathVariable String email, + // @RequestParam("postId") String postId, + // @RequestParam("discription") String discription) { + + // return postService.addPoastDiscription(email, postId, discription); + // } + + // @PostMapping("/test/addImagesToPost/{email}") // public ResponseEntity addImagesToPostForTest( // @PathVariable String email, diff --git a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java new file mode 100644 index 0000000..501cd9e --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java @@ -0,0 +1,93 @@ +package com.cadac.stone_inscription.report.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.dto.CreateReportRequest; +import com.cadac.stone_inscription.report.dto.ModerateReportRequest; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.service.ReportService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + private final JwtUtil jwtUtil; + + @PostMapping("/report") + @Secured({ "user", "admin" }) + public ResponseEntity createReport( + HttpServletRequest request, + @Valid @RequestBody CreateReportRequest createReportRequest) { + + return reportService.createReport(extractEmailFromToken(request), createReportRequest); + } + + @PostMapping("/test/report/{email}") + public ResponseEntity createReportForTest( + @PathVariable String email, + @Valid @RequestBody CreateReportRequest createReportRequest) { + + return reportService.createReport(email, createReportRequest); + } + + @GetMapping("/reports") + @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + public ResponseEntity getReports( + HttpServletRequest request, + @RequestParam(required = false) ReportStatus status) { + + return reportService.getReports(extractEmailFromToken(request), status); + } + + @GetMapping("/test/reports/{email}") + public ResponseEntity getReportsForTest( + @PathVariable String email, + @RequestParam(required = false) ReportStatus status) { + + return reportService.getReports(email, status); + } + + @PostMapping("/moderate/{id}") + @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + public ResponseEntity moderateReport( + HttpServletRequest request, + @PathVariable String id, + @RequestBody(required = false) ModerateReportRequest moderateReportRequest) { + + return reportService.moderateReport(extractEmailFromToken(request), id, moderateReportRequest); + } + + @PostMapping("/test/moderate/{id}/{email}") + public ResponseEntity moderateReportForTest( + @PathVariable String id, + @PathVariable String email, + @RequestBody(required = false) ModerateReportRequest moderateReportRequest) { + + return reportService.moderateReport(email, id, moderateReportRequest); + } + + private String extractEmailFromToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + + if (token == null || !token.startsWith("Bearer ")) { + throw new StoneInscriptionException("Invalid or missing authorization token", HttpStatus.UNAUTHORIZED); + } + + return jwtUtil.getUsernameFromToken(token.substring(7)); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java new file mode 100644 index 0000000..2390801 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java @@ -0,0 +1,26 @@ +package com.cadac.stone_inscription.report.dto; + +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportTargetType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CreateReportRequest { + + @NotNull + private ReportTargetType targetType; + + @NotBlank + private String targetId; + + @NotNull + private ReportReason reason; + + @NotBlank + @Size(max = 1000) + private String details; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java new file mode 100644 index 0000000..35e26c5 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java @@ -0,0 +1,15 @@ +package com.cadac.stone_inscription.report.dto; + +import com.cadac.stone_inscription.report.enums.ModerationAction; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class ModerateReportRequest { + + private ModerationAction action; + + @Size(max = 1000) + private String note; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java new file mode 100644 index 0000000..290e8f6 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java @@ -0,0 +1,165 @@ +package com.cadac.stone_inscription.report.entity; + +import java.util.ArrayList; +import java.util.Date; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bson.types.ObjectId; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.http.HttpStatus; + +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "moderation_reports") +@CompoundIndexes({ + @CompoundIndex(name = "report_target_idx", def = "{'targetId': 1, 'targetType': 1}"), + @CompoundIndex(name = "reporter_target_idx", def = "{'reporterId': 1, 'targetId': 1, 'targetType': 1}") +}) +public class ModerationReport { + + @Id + @JsonProperty("_id") + @JsonSerialize(using = ToStringSerializer.class) + private ObjectId id; + + @Field("reporterId") + @JsonProperty("reporterId") + @Indexed + private String reporterId; + + @Field("targetId") + @JsonProperty("targetId") + @Indexed + private String targetId; + + @Field("targetType") + @JsonProperty("targetType") + private ReportTargetType targetType; + + @Field("targetAuthorId") + @JsonProperty("targetAuthorId") + private String targetAuthorId; + + @Field("reason") + @JsonProperty("reason") + private ReportReason reason; + + @Field("details") + @JsonProperty("details") + private String details; + + @Field("status") + @JsonProperty("status") + @Indexed + private ReportStatus status; + + @Field("actionTaken") + @JsonProperty("actionTaken") + @Builder.Default + private ModerationAction actionTaken = ModerationAction.NONE; + + @Field("resolvedBy") + @JsonProperty("resolvedBy") + private String resolvedBy; + + @Field("aiConfidenceScore") + @JsonProperty("aiConfidenceScore") + @Builder.Default + private Double aiConfidenceScore = 0.0; + + @CreatedDate + @Field("createdAt") + @JsonProperty("createdAt") + private Date createdAt; + + @LastModifiedDate + @Field("updatedAt") + @JsonProperty("updatedAt") + private Date updatedAt; + + @Field("resolvedAt") + @JsonProperty("resolvedAt") + private Date resolvedAt; + + @Field("auditEntries") + @JsonProperty("auditEntries") + @Builder.Default + private List auditEntries = new ArrayList<>(); + + public void addAuditEntry(String actor, String message) { + auditEntries.add(ReportAuditEntry.builder() + .actor(actor) + .message(message) + .createdAt(new Date()) + .build()); + } + + public void setAiConfidenceScore(double score, String actor) { + this.aiConfidenceScore = score; + addAuditEntry(actor, String.format("AI confidence score set to %.2f", score)); + } + + public void transitionTo(ReportStatus newStatus, String actor, ModerationAction action, String note) { + validateTransition(this.status, newStatus); + this.status = newStatus; + this.actionTaken = action; + this.resolvedBy = actor; + + if (newStatus == ReportStatus.RESOLVED) { + this.resolvedAt = new Date(); + } + + StringBuilder builder = new StringBuilder() + .append("Status -> ").append(newStatus) + .append(" | Action -> ").append(action) + .append(" | By -> ").append(actor); + if (note != null && !note.isBlank()) { + builder.append(" | Note -> ").append(note.trim()); + } + addAuditEntry(actor, builder.toString()); + } + + private void validateTransition(ReportStatus from, ReportStatus to) { + if (from == null) { + return; + } + + Map> allowedTransitions = new EnumMap<>(ReportStatus.class); + allowedTransitions.put(ReportStatus.PENDING, Set.of(ReportStatus.AI_SCREENING)); + allowedTransitions.put(ReportStatus.AI_SCREENING, Set.of(ReportStatus.ESCALATED, ReportStatus.RESOLVED)); + allowedTransitions.put(ReportStatus.ESCALATED, Set.of(ReportStatus.RESOLVED)); + allowedTransitions.put(ReportStatus.RESOLVED, Set.of()); + + if (!allowedTransitions.getOrDefault(from, Set.of()).contains(to)) { + throw new StoneInscriptionException( + "Invalid report status transition: " + from + " -> " + to, + HttpStatus.BAD_REQUEST); + } + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java new file mode 100644 index 0000000..679b197 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java @@ -0,0 +1,31 @@ +package com.cadac.stone_inscription.report.entity; + +import java.util.Date; + +import org.springframework.data.mongodb.core.mapping.Field; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReportAuditEntry { + + @Field("actor") + @JsonProperty("actor") + private String actor; + + @Field("message") + @JsonProperty("message") + private String message; + + @Field("createdAt") + @JsonProperty("createdAt") + private Date createdAt; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/enums/ModerationAction.java b/src/main/java/com/cadac/stone_inscription/report/enums/ModerationAction.java new file mode 100644 index 0000000..80a239c --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/enums/ModerationAction.java @@ -0,0 +1,11 @@ +package com.cadac.stone_inscription.report.enums; + +public enum ModerationAction { + NONE, + WARN, + REMOVE_CONTENT, + BAN_REPORTER, + BAN_AUTHOR, + ESCALATE, + DISMISS +} diff --git a/src/main/java/com/cadac/stone_inscription/report/enums/ReportReason.java b/src/main/java/com/cadac/stone_inscription/report/enums/ReportReason.java new file mode 100644 index 0000000..6ff827e --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/enums/ReportReason.java @@ -0,0 +1,10 @@ +package com.cadac.stone_inscription.report.enums; + +public enum ReportReason { + SPAM, + HATE_SPEECH, + MISINFORMATION, + HARASSMENT, + EXPLICIT_CONTENT, + OTHER +} diff --git a/src/main/java/com/cadac/stone_inscription/report/enums/ReportStatus.java b/src/main/java/com/cadac/stone_inscription/report/enums/ReportStatus.java new file mode 100644 index 0000000..d6526d7 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/enums/ReportStatus.java @@ -0,0 +1,8 @@ +package com.cadac.stone_inscription.report.enums; + +public enum ReportStatus { + PENDING, + AI_SCREENING, + ESCALATED, + RESOLVED +} diff --git a/src/main/java/com/cadac/stone_inscription/report/enums/ReportTargetType.java b/src/main/java/com/cadac/stone_inscription/report/enums/ReportTargetType.java new file mode 100644 index 0000000..c12def2 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/enums/ReportTargetType.java @@ -0,0 +1,7 @@ +package com.cadac.stone_inscription.report.enums; + +public enum ReportTargetType { + POST, + COMMENT, + USER +} diff --git a/src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java b/src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java new file mode 100644 index 0000000..4a56c25 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java @@ -0,0 +1,33 @@ +package com.cadac.stone_inscription.report.factory; + +import org.bson.types.ObjectId; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.report.dto.CreateReportRequest; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; + +@Component +public class ReportFactory { + + public ModerationReport create(User reporter, ResolvedReportTarget target, CreateReportRequest request) { + ModerationReport report = ModerationReport.builder() + .id(new ObjectId()) + .reporterId(reporter.getId().toHexString()) + .targetId(target.getId()) + .targetType(target.getType()) + .targetAuthorId(target.getAuthorId()) + .reason(request.getReason()) + .details(request.getDetails().trim()) + .status(ReportStatus.PENDING) + .actionTaken(ModerationAction.NONE) + .aiConfidenceScore(0.0) + .build(); + + report.addAuditEntry(reporter.getId().toHexString(), "Report created"); + return report; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java b/src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java new file mode 100644 index 0000000..1574264 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java @@ -0,0 +1,71 @@ +package com.cadac.stone_inscription.report.moderation; + +import java.util.List; +import java.util.Locale; + +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportStatus; + +@Component +public class AiModerationHandler extends ModerationHandler { + + private static final String AI_ACTOR = "AI_MODERATOR"; + private static final double AUTO_RESOLVE_THRESHOLD = 0.85; + + @Override + public void handle(ModerationExecutionContext context) { + ModerationReport report = context.getReport(); + if (report.getStatus() != ReportStatus.PENDING) { + passToNext(context); + return; + } + + report.transitionTo(ReportStatus.AI_SCREENING, AI_ACTOR, ModerationAction.NONE, context.getNote()); + + double score = computeConfidenceScore(context); + report.setAiConfidenceScore(score, AI_ACTOR); + + if (score >= AUTO_RESOLVE_THRESHOLD) { + ModerationAction action = determineAutoAction(report.getReason()); + context.getReportActionService().applyAction(report, context.getTarget(), action, AI_ACTOR, context.getNote()); + report.transitionTo(ReportStatus.RESOLVED, AI_ACTOR, action, context.getNote()); + return; + } + + report.transitionTo(ReportStatus.ESCALATED, AI_ACTOR, ModerationAction.ESCALATE, context.getNote()); + } + + private double computeConfidenceScore(ModerationExecutionContext context) { + ModerationReport report = context.getReport(); + String combinedText = (context.getTarget().getContent() + " " + report.getDetails()).toLowerCase(Locale.ROOT); + + double base = switch (report.getReason()) { + case HATE_SPEECH -> 0.80; + case EXPLICIT_CONTENT -> 0.75; + case HARASSMENT -> 0.70; + case SPAM -> 0.65; + case MISINFORMATION -> 0.55; + case OTHER -> 0.30; + }; + + List flaggedTerms = List.of("hate", "spam", "scam", "fake", "explicit", "kill", "abuse"); + boolean hasFlaggedTerms = flaggedTerms.stream().anyMatch(combinedText::contains); + + if (hasFlaggedTerms) { + base += 0.20; + } + + return Math.min(base, 1.0); + } + + private ModerationAction determineAutoAction(ReportReason reason) { + return switch (reason) { + case SPAM, EXPLICIT_CONTENT, HARASSMENT, HATE_SPEECH, MISINFORMATION -> ModerationAction.REMOVE_CONTENT; + case OTHER -> ModerationAction.ESCALATE; + }; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/moderation/HumanModerationHandler.java b/src/main/java/com/cadac/stone_inscription/report/moderation/HumanModerationHandler.java new file mode 100644 index 0000000..4e987c6 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/moderation/HumanModerationHandler.java @@ -0,0 +1,54 @@ +package com.cadac.stone_inscription.report.moderation; + +import java.util.EnumSet; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportStatus; + +@Component +public class HumanModerationHandler extends ModerationHandler { + + private static final EnumSet ALLOWED_ACTIONS = EnumSet.of( + ModerationAction.WARN, + ModerationAction.REMOVE_CONTENT, + ModerationAction.BAN_REPORTER, + ModerationAction.BAN_AUTHOR, + ModerationAction.DISMISS); + + @Override + public void handle(ModerationExecutionContext context) { + ModerationReport report = context.getReport(); + if (report.getStatus() != ReportStatus.ESCALATED) { + passToNext(context); + return; + } + + if (context.getActor() != null + && context.getActor().getId() != null + && context.getActor().getId().toHexString().equals(context.getTarget().getAuthorId())) { + throw new StoneInscriptionException( + "A moderator cannot moderate their own content.", + HttpStatus.BAD_REQUEST); + } + + if (context.getRequestedAction() == null || !ALLOWED_ACTIONS.contains(context.getRequestedAction())) { + throw new StoneInscriptionException( + "A valid moderation action is required for escalated reports.", + HttpStatus.BAD_REQUEST); + } + + context.getReportActionService().applyAction( + report, + context.getTarget(), + context.getRequestedAction(), + context.getActorLabel(), + context.getNote()); + + report.transitionTo(ReportStatus.RESOLVED, context.getActorLabel(), context.getRequestedAction(), context.getNote()); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationExecutionContext.java b/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationExecutionContext.java new file mode 100644 index 0000000..0143f79 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationExecutionContext.java @@ -0,0 +1,23 @@ +package com.cadac.stone_inscription.report.moderation; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.service.ReportActionService; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ModerationExecutionContext { + + private final ModerationReport report; + private final ResolvedReportTarget target; + private final User actor; + private final String actorLabel; + private final ModerationAction requestedAction; + private final String note; + private final ReportActionService reportActionService; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationHandler.java b/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationHandler.java new file mode 100644 index 0000000..01efe0c --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/moderation/ModerationHandler.java @@ -0,0 +1,19 @@ +package com.cadac.stone_inscription.report.moderation; + +public abstract class ModerationHandler { + + protected ModerationHandler next; + + public ModerationHandler setNext(ModerationHandler next) { + this.next = next; + return next; + } + + public abstract void handle(ModerationExecutionContext context); + + protected void passToNext(ModerationExecutionContext context) { + if (next != null) { + next.handle(context); + } + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java b/src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java new file mode 100644 index 0000000..141894a --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java @@ -0,0 +1,26 @@ +package com.cadac.stone_inscription.report.repository; + +import java.util.Collection; +import java.util.List; + +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.enums.ReportTargetType; + +@Repository +public interface ModerationReportRepository extends MongoRepository { + + List findByStatusOrderByCreatedAtDesc(ReportStatus status); + + List findAllByOrderByCreatedAtDesc(); + + boolean existsByReporterIdAndTargetIdAndTargetTypeAndStatusIn( + String reporterId, + String targetId, + ReportTargetType targetType, + Collection statuses); +} diff --git a/src/main/java/com/cadac/stone_inscription/report/resolver/ReportTargetResolver.java b/src/main/java/com/cadac/stone_inscription/report/resolver/ReportTargetResolver.java new file mode 100644 index 0000000..95aa10d --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/resolver/ReportTargetResolver.java @@ -0,0 +1,86 @@ +package com.cadac.stone_inscription.report.resolver; + +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.entity.InscriptionPost; +import com.cadac.stone_inscription.entity.PublicPostDescription; +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.repository.InscriptionPostRepo; +import com.cadac.stone_inscription.repository.PublicPostDescriptionRepo; +import com.cadac.stone_inscription.repository.UserRepository; +import com.cadac.stone_inscription.report.enums.ReportTargetType; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ReportTargetResolver { + + private final InscriptionPostRepo inscriptionPostRepo; + private final PublicPostDescriptionRepo publicPostDescriptionRepo; + private final UserRepository userRepository; + + public ResolvedReportTarget resolve(ReportTargetType targetType, String targetId) { + if (!ObjectId.isValid(targetId)) { + throw new StoneInscriptionException("Invalid target id", HttpStatus.BAD_REQUEST); + } + + ObjectId objectId = new ObjectId(targetId); + + return switch (targetType) { + case POST -> resolvePost(objectId); + case COMMENT -> resolveComment(objectId); + case USER -> resolveUser(objectId); + }; + } + + private ResolvedReportTarget resolvePost(ObjectId objectId) { + InscriptionPost post = inscriptionPostRepo.findById(objectId) + .orElseThrow(() -> new StoneInscriptionException("Post not found", HttpStatus.NOT_FOUND)); + + String content = Optional.ofNullable(post.getDescription()) + .map(InscriptionPost.Description::getDescription) + .orElse(""); + + return ResolvedReportTarget.builder() + .id(post.getId().toHexString()) + .authorId(post.getUserId().toHexString()) + .type(ReportTargetType.POST) + .content(content) + .entity(post) + .build(); + } + + private ResolvedReportTarget resolveComment(ObjectId objectId) { + PublicPostDescription comment = publicPostDescriptionRepo.findById(objectId) + .orElseThrow(() -> new StoneInscriptionException("Comment not found", HttpStatus.NOT_FOUND)); + + return ResolvedReportTarget.builder() + .id(comment.getId().toHexString()) + .authorId(comment.getUserId().toHexString()) + .type(ReportTargetType.COMMENT) + .content(comment.getDescription()) + .entity(comment) + .build(); + } + + private ResolvedReportTarget resolveUser(ObjectId objectId) { + User user = userRepository.findById(objectId) + .orElseThrow(() -> new StoneInscriptionException("User not found", HttpStatus.NOT_FOUND)); + + String content = user.getBio() == null ? "" : user.getBio(); + + return ResolvedReportTarget.builder() + .id(user.getId().toHexString()) + .authorId(user.getId().toHexString()) + .type(ReportTargetType.USER) + .content(content) + .entity(user) + .build(); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/resolver/ResolvedReportTarget.java b/src/main/java/com/cadac/stone_inscription/report/resolver/ResolvedReportTarget.java new file mode 100644 index 0000000..b44ccaa --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/resolver/ResolvedReportTarget.java @@ -0,0 +1,17 @@ +package com.cadac.stone_inscription.report.resolver; + +import com.cadac.stone_inscription.report.enums.ReportTargetType; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ResolvedReportTarget { + + private final String id; + private final String authorId; + private final ReportTargetType type; + private final String content; + private final Object entity; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/service/ReportActionService.java b/src/main/java/com/cadac/stone_inscription/report/service/ReportActionService.java new file mode 100644 index 0000000..1af3d6e --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/service/ReportActionService.java @@ -0,0 +1,188 @@ +package com.cadac.stone_inscription.report.service; + +import org.bson.types.ObjectId; +import org.springframework.stereotype.Service; + +import com.cadac.stone_inscription.content.delete.ContentDeleteService; +import com.cadac.stone_inscription.entity.InscriptionPost; +import com.cadac.stone_inscription.entity.PublicPostDescription; +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.entity.enums.PostStatus; +import com.cadac.stone_inscription.entity.model.ReportEntry; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.repository.InscriptionPostRepo; +import com.cadac.stone_inscription.repository.PublicPostDescriptionRepo; +import com.cadac.stone_inscription.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportActionService { + + private final InscriptionPostRepo inscriptionPostRepo; + private final PublicPostDescriptionRepo publicPostDescriptionRepo; + private final UserRepository userRepository; + private final ContentDeleteService contentDeleteService; + + public void markTargetUnderReview(ResolvedReportTarget target, User reporter, String details) { + if (target.getType() == ReportTargetType.POST) { + InscriptionPost post = (InscriptionPost) target.getEntity(); + post.setStatus(PostStatus.UNDER_REVIEW); + if (post.getReport() != null && post.getReport().getReporters() != null) { + post.getReport().getReporters().add(buildReportEntry(reporter, details)); + } + inscriptionPostRepo.save(post); + return; + } + + if (target.getType() == ReportTargetType.COMMENT) { + PublicPostDescription comment = (PublicPostDescription) target.getEntity(); + comment.setStatus(PostStatus.UNDER_REVIEW); + if (comment.getReport() != null && comment.getReport().getReporters() != null) { + comment.getReport().getReporters().add(buildReportEntry(reporter, details)); + } + publicPostDescriptionRepo.save(comment); + } + } + + public void applyAction( + ModerationReport report, + ResolvedReportTarget target, + ModerationAction action, + String actor, + String note) { + + switch (action) { + case NONE, ESCALATE -> { + return; + } + case WARN -> { + incrementAuthorReportCount(target.getAuthorId(), false); + incrementValidatedTargetReportCount(target); + restoreTargetAcceptance(target); + report.addAuditEntry(actor, appendNote("Warned target author", note)); + } + case REMOVE_CONTENT -> { + incrementValidatedTargetReportCount(target); + removeTargetContent(target); + incrementAuthorReportCount(target.getAuthorId(), false); + report.addAuditEntry(actor, appendNote("Removed reported target content", note)); + } + case BAN_AUTHOR -> { + incrementValidatedTargetReportCount(target); + removeTargetContent(target); + incrementAuthorReportCount(target.getAuthorId(), true); + report.addAuditEntry(actor, appendNote("Banned target author", note)); + } + case BAN_REPORTER -> { + blackListUser(report.getReporterId()); + restoreTargetAcceptance(target); + report.addAuditEntry(actor, appendNote("Blacklisted reporter", note)); + } + case DISMISS -> { + restoreTargetAcceptance(target); + report.addAuditEntry(actor, appendNote("Dismissed report", note)); + } + } + } + + private ReportEntry buildReportEntry(User reporter, String details) { + return ReportEntry.builder() + .userId(reporter.getId().toHexString()) + .name(reporter.getName()) + .reason(details) + .build(); + } + + private void removeTargetContent(ResolvedReportTarget target) { + if (target.getType() == ReportTargetType.USER) { + return; + } + + if (target.getType() == ReportTargetType.POST) { + contentDeleteService.deletePost(new ObjectId(target.getId())); + return; + } + + if (target.getType() == ReportTargetType.COMMENT) { + contentDeleteService.deleteComment(new ObjectId(target.getId())); + } + } + + private void restoreTargetAcceptance(ResolvedReportTarget target) { + if (target.getType() == ReportTargetType.USER) { + return; + } + + if (target.getType() == ReportTargetType.POST) { + InscriptionPost post = (InscriptionPost) target.getEntity(); + post.setStatus(PostStatus.ACCEPTED); + inscriptionPostRepo.save(post); + return; + } + + if (target.getType() == ReportTargetType.COMMENT) { + PublicPostDescription comment = (PublicPostDescription) target.getEntity(); + comment.setStatus(PostStatus.ACCEPTED); + publicPostDescriptionRepo.save(comment); + } + } + + private void incrementAuthorReportCount(String userId, boolean blackList) { + if (!ObjectId.isValid(userId)) { + return; + } + + userRepository.findById(new ObjectId(userId)).ifPresent(user -> { + int currentCount = user.getReportCount() == null ? 0 : user.getReportCount(); + user.setReportCount(currentCount + 1); + if (blackList) { + user.setBlackListed(true); + } + userRepository.save(user); + }); + } + + private void incrementValidatedTargetReportCount(ResolvedReportTarget target) { + if (target.getType() == ReportTargetType.POST) { + InscriptionPost post = (InscriptionPost) target.getEntity(); + if (post.getReport() != null) { + int currentCount = post.getReport().getCount() == null ? 0 : post.getReport().getCount(); + post.getReport().setCount(currentCount + 1); + inscriptionPostRepo.save(post); + } + return; + } + + if (target.getType() == ReportTargetType.COMMENT) { + PublicPostDescription comment = (PublicPostDescription) target.getEntity(); + if (comment.getReport() != null) { + int currentCount = comment.getReport().getCount() == null ? 0 : comment.getReport().getCount(); + comment.getReport().setCount(currentCount + 1); + publicPostDescriptionRepo.save(comment); + } + } + } + + private void blackListUser(String userId) { + if (!ObjectId.isValid(userId)) { + return; + } + + userRepository.findById(new ObjectId(userId)).ifPresent(user -> { + user.setBlackListed(true); + userRepository.save(user); + }); + } + + private String appendNote(String baseMessage, String note) { + if (note == null || note.isBlank()) { + return baseMessage; + } + return baseMessage + " | " + note.trim(); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java b/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java new file mode 100644 index 0000000..8ce0a6c --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java @@ -0,0 +1,160 @@ +package com.cadac.stone_inscription.report.service; + +import java.util.List; + +import org.bson.types.ObjectId; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.entity.UserAuth; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.dto.CreateReportRequest; +import com.cadac.stone_inscription.report.dto.ModerateReportRequest; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.factory.ReportFactory; +import com.cadac.stone_inscription.report.moderation.AiModerationHandler; +import com.cadac.stone_inscription.report.moderation.HumanModerationHandler; +import com.cadac.stone_inscription.report.moderation.ModerationExecutionContext; +import com.cadac.stone_inscription.report.moderation.ModerationHandler; +import com.cadac.stone_inscription.report.repository.ModerationReportRepository; +import com.cadac.stone_inscription.report.resolver.ReportTargetResolver; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.specification.ReportValidationContext; +import com.cadac.stone_inscription.report.specification.Specification; +import com.cadac.stone_inscription.repository.UserAuthRepository; +import com.cadac.stone_inscription.repository.UserRepository; +import com.cadac.stone_inscription.util.UserResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ModerationReportRepository moderationReportRepository; + private final UserRepository userRepository; + private final UserAuthRepository userAuthRepository; + private final ReportFactory reportFactory; + private final ReportTargetResolver reportTargetResolver; + private final ReportActionService reportActionService; + private final AiModerationHandler aiModerationHandler; + private final HumanModerationHandler humanModerationHandler; + private final List> reportSpecifications; + + public ResponseEntity createReport(String reporterEmail, CreateReportRequest request) { + User reporter = getUserByEmail(reporterEmail); + ResolvedReportTarget target = reportTargetResolver.resolve(request.getTargetType(), request.getTargetId()); + + validateReportRequest(reporter, target); + + ModerationReport report = reportFactory.create(reporter, target, request); + moderationReportRepository.save(report); + reportActionService.markTargetUnderReview(target, reporter, request.getDetails()); + + return UserResponse.responseHandler("Report created successfully", HttpStatus.CREATED, report); + } + + public ResponseEntity getReports(String requesterEmail, ReportStatus status) { + ensureModerator(requesterEmail); + + List reports = status == null + ? moderationReportRepository.findAllByOrderByCreatedAtDesc() + : moderationReportRepository.findByStatusOrderByCreatedAtDesc(status); + + return UserResponse.responseHandler("Reports fetched successfully", HttpStatus.OK, reports); + } + + public ResponseEntity moderateReport(String actorEmail, String reportId, ModerateReportRequest request) { + User actor = getUserByEmail(actorEmail); + ModerationReport report = moderationReportRepository.findById(parseObjectId(reportId)) + .orElseThrow(() -> new StoneInscriptionException("Report not found", HttpStatus.NOT_FOUND)); + + ResolvedReportTarget target = reportTargetResolver.resolve(report.getTargetType(), report.getTargetId()); + + if (report.getStatus() == ReportStatus.RESOLVED) { + throw new StoneInscriptionException("Report is already resolved", HttpStatus.BAD_REQUEST); + } + + if (report.getStatus() == ReportStatus.ESCALATED) { + ensureModerator(actorEmail); + } + + ModerationHandler moderationPipeline = buildModerationPipeline(); + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(target) + .actor(actor) + .actorLabel(actor.getId().toHexString()) + .requestedAction(request == null ? null : request.getAction()) + .note(request == null ? null : request.getNote()) + .reportActionService(reportActionService) + .build(); + + moderationPipeline.handle(context); + moderationReportRepository.save(report); + + String message = report.getStatus() == ReportStatus.ESCALATED + ? "Report escalated for human moderation" + : "Report moderation completed"; + + return UserResponse.responseHandler(message, HttpStatus.OK, report); + } + + private void validateReportRequest(User reporter, ResolvedReportTarget target) { + ReportValidationContext context = ReportValidationContext.builder() + .reporter(reporter) + .target(target) + .reportRepository(moderationReportRepository) + .build(); + + List violations = reportSpecifications.stream() + .filter(specification -> !specification.isSatisfiedBy(context)) + .map(Specification::errorMessage) + .toList(); + + if (!violations.isEmpty()) { + throw new StoneInscriptionException(String.join(" ", violations), HttpStatus.BAD_REQUEST); + } + } + + private ModerationHandler buildModerationPipeline() { + aiModerationHandler.setNext(humanModerationHandler); + return aiModerationHandler; + } + + private User getUserByEmail(String email) { + User user = userRepository.findByEmail(email); + if (user == null) { + throw new StoneInscriptionException("User not found", HttpStatus.NOT_FOUND); + } + return user; + } + + private void ensureModerator(String email) { + UserAuth userAuth = userAuthRepository.findByEmail(email); + if (userAuth == null || userAuth.getRoles() == null) { + throw new StoneInscriptionException("Moderator access required", HttpStatus.FORBIDDEN); + } + + boolean hasModeratorRole = userAuth.getRoles().stream() + .map(String::toLowerCase) + .anyMatch(role -> role.equals("admin") + || role.equals("moderator") + || role.equals("human_moderator") + || role.equals("ai_moderator")); + + if (!hasModeratorRole) { + throw new StoneInscriptionException("Moderator access required", HttpStatus.FORBIDDEN); + } + } + + private ObjectId parseObjectId(String id) { + if (!ObjectId.isValid(id)) { + throw new StoneInscriptionException("Invalid report id", HttpStatus.BAD_REQUEST); + } + return new ObjectId(id); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/NotDuplicateReportSpecification.java b/src/main/java/com/cadac/stone_inscription/report/specification/NotDuplicateReportSpecification.java new file mode 100644 index 0000000..068d63f --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/NotDuplicateReportSpecification.java @@ -0,0 +1,25 @@ +package com.cadac.stone_inscription.report.specification; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.report.enums.ReportStatus; + +@Component +public class NotDuplicateReportSpecification implements Specification { + + @Override + public boolean isSatisfiedBy(ReportValidationContext candidate) { + return !candidate.getReportRepository().existsByReporterIdAndTargetIdAndTargetTypeAndStatusIn( + candidate.getReporter().getId().toHexString(), + candidate.getTarget().getId(), + candidate.getTarget().getType(), + List.of(ReportStatus.PENDING, ReportStatus.AI_SCREENING, ReportStatus.ESCALATED)); + } + + @Override + public String errorMessage() { + return "You have already reported this target."; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/NotSelfReportSpecification.java b/src/main/java/com/cadac/stone_inscription/report/specification/NotSelfReportSpecification.java new file mode 100644 index 0000000..a26c0a7 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/NotSelfReportSpecification.java @@ -0,0 +1,17 @@ +package com.cadac.stone_inscription.report.specification; + +import org.springframework.stereotype.Component; + +@Component +public class NotSelfReportSpecification implements Specification { + + @Override + public boolean isSatisfiedBy(ReportValidationContext candidate) { + return !candidate.getReporter().getId().toHexString().equals(candidate.getTarget().getAuthorId()); + } + + @Override + public String errorMessage() { + return "A user cannot report their own content."; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/ReportValidationContext.java b/src/main/java/com/cadac/stone_inscription/report/specification/ReportValidationContext.java new file mode 100644 index 0000000..ccdb76f --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/ReportValidationContext.java @@ -0,0 +1,17 @@ +package com.cadac.stone_inscription.report.specification; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.report.repository.ModerationReportRepository; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReportValidationContext { + + private final User reporter; + private final ResolvedReportTarget target; + private final ModerationReportRepository reportRepository; +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/ReporterNotBlacklistedSpecification.java b/src/main/java/com/cadac/stone_inscription/report/specification/ReporterNotBlacklistedSpecification.java new file mode 100644 index 0000000..6110bcf --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/ReporterNotBlacklistedSpecification.java @@ -0,0 +1,17 @@ +package com.cadac.stone_inscription.report.specification; + +import org.springframework.stereotype.Component; + +@Component +public class ReporterNotBlacklistedSpecification implements Specification { + + @Override + public boolean isSatisfiedBy(ReportValidationContext candidate) { + return candidate.getReporter().getBlackListed() == null || !candidate.getReporter().getBlackListed(); + } + + @Override + public String errorMessage() { + return "Blacklisted users cannot file reports."; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/Specification.java b/src/main/java/com/cadac/stone_inscription/report/specification/Specification.java new file mode 100644 index 0000000..57ac046 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/Specification.java @@ -0,0 +1,8 @@ +package com.cadac.stone_inscription.report.specification; + +public interface Specification { + + boolean isSatisfiedBy(T candidate); + + String errorMessage(); +} diff --git a/src/main/java/com/cadac/stone_inscription/report/specification/TargetNotBlacklistedSpecification.java b/src/main/java/com/cadac/stone_inscription/report/specification/TargetNotBlacklistedSpecification.java new file mode 100644 index 0000000..b05cc70 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/specification/TargetNotBlacklistedSpecification.java @@ -0,0 +1,25 @@ +package com.cadac.stone_inscription.report.specification; + +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.report.enums.ReportTargetType; + +@Component +public class TargetNotBlacklistedSpecification implements Specification { + + @Override + public boolean isSatisfiedBy(ReportValidationContext candidate) { + if (candidate.getTarget().getType() != ReportTargetType.USER) { + return true; + } + + User user = (User) candidate.getTarget().getEntity(); + return user.getBlackListed() == null || !user.getBlackListed(); + } + + @Override + public String errorMessage() { + return "This user is already blacklisted."; + } +} diff --git a/src/test/java/com/cadac/stone_inscription/moderation/ContentModerationServiceTests.java b/src/test/java/com/cadac/stone_inscription/moderation/ContentModerationServiceTests.java new file mode 100644 index 0000000..e9c9573 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/moderation/ContentModerationServiceTests.java @@ -0,0 +1,98 @@ +package com.cadac.stone_inscription.moderation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.cadac.stone_inscription.moderation.client.N8nModerationClient; +import com.cadac.stone_inscription.moderation.config.ContentModerationProperties; +import com.cadac.stone_inscription.moderation.dto.ContentModerationRequestDto; +import com.cadac.stone_inscription.moderation.model.ContentModerationResult; +import com.cadac.stone_inscription.moderation.service.ContentModerationService; + +class ContentModerationServiceTests { + + @Test + void moderateUsesNestedWrappedResponseFromWebhook() { + ContentModerationService service = new ContentModerationService(new StubModerationClient(""" + { + "decision": "REVIEW", + "status": "PENDING_REVIEW", + "result": { + "decision": "ALLOW", + "status": "APPROVED", + "confidence": 0.94, + "label": "safe" + } + } + """), properties(0.7)); + + ContentModerationResult result = service.moderate("Temple Stone", "history", "Ancient inscription details"); + + assertTrue(result.isApproved()); + assertEquals("ALLOW", result.getDecision()); + assertEquals("APPROVED", result.getStatus()); + assertEquals(0.94, result.getConfidence()); + assertEquals("SAFE", result.getLabel()); + } + + @Test + void moderateAllowsPendingReviewResponsesToBeSaved() { + ContentModerationService service = new ContentModerationService(new StubModerationClient(""" + { + "decision": "REVIEW", + "status": "pending_review", + "confidence": 0, + "id": 5006 + } + """), properties(0.7)); + + ContentModerationResult result = service.moderate("Temple Stone", "history", "Normal educational content"); + + assertTrue(result.isApproved()); + assertEquals("REVIEW", result.getDecision()); + assertEquals("PENDING_REVIEW", result.getStatus()); + assertEquals(0.0, result.getConfidence()); + } + + @Test + void moderateSupportsCommonAliasFieldsFromWebhook() { + ContentModerationService service = new ContentModerationService(new StubModerationClient(""" + { + "verdict": "approved", + "state": "allow", + "score": 0.91, + "classification": "safe" + } + """), properties(0.7)); + + ContentModerationResult result = service.moderate("Artifact", "epigraphy", "Clearly valid content"); + + assertTrue(result.isApproved()); + assertEquals("APPROVED", result.getDecision()); + assertEquals("ALLOW", result.getStatus()); + assertEquals(0.91, result.getConfidence()); + assertEquals("SAFE", result.getLabel()); + } + + private ContentModerationProperties properties(double threshold) { + ContentModerationProperties properties = new ContentModerationProperties(); + properties.setSafeThreshold(threshold); + return properties; + } + + private static class StubModerationClient extends N8nModerationClient { + private final String response; + + StubModerationClient(String response) { + super(new ContentModerationProperties()); + this.response = response; + } + + @Override + public String moderate(ContentModerationRequestDto request) { + return response; + } + } +} diff --git a/src/test/java/com/cadac/stone_inscription/post/controller/PostControllerTests.java b/src/test/java/com/cadac/stone_inscription/post/controller/PostControllerTests.java new file mode 100644 index 0000000..f7df35e --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/post/controller/PostControllerTests.java @@ -0,0 +1,168 @@ +package com.cadac.stone_inscription.post.controller; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.access.annotation.Secured; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.post.dto.InscriptionPostDto; +import com.cadac.stone_inscription.post.service.PostService; + +class PostControllerTests { + + @Test + void addPoastDiscriptionRequiresUserRole() throws NoSuchMethodException { + Method method = PostController.class.getMethod( + "addPoastDiscription", + jakarta.servlet.http.HttpServletRequest.class, + String.class, + String.class); + + Secured secured = method.getAnnotation(Secured.class); + + assertArrayEquals(new String[] { "user" }, secured.value()); + } + + @Test + void addPoastDiscriptionPassesDecodedUserAndCommentToService() throws Exception { + PostController controller = new PostController(); + TrackingPostService postService = new TrackingPostService(); + JwtUtil jwtUtil = newJwtUtil(); + + ReflectionTestUtils.setField(controller, "postService", postService); + ReflectionTestUtils.setField(controller, "jwtUtil", jwtUtil); + + String token = jwtUtil.doGenerateToken(java.util.Map.of("user", "tester@example.com", "role", "user")); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + token); + + ResponseEntity expectedResponse = ResponseEntity.ok("saved"); + postService.addDescriptionResponse = expectedResponse; + + ResponseEntity response = controller.addPoastDiscription( + request, + "680f6c0b5a4e3b2d1c9f1234", + "Public description for the post"); + + assertSame(expectedResponse, response); + assertEquals("tester@example.com", postService.usernameFromToken); + assertEquals("680f6c0b5a4e3b2d1c9f1234", postService.postId); + assertEquals("Public description for the post", postService.description); + } + + private JwtUtil newJwtUtil() throws Exception { + Constructor constructor = JwtUtil.class.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private static class TrackingPostService implements PostService { + private ResponseEntity addDescriptionResponse; + private String usernameFromToken; + private String postId; + private String description; + + @Override + public ResponseEntity addPoastDiscription(String usernameFromToken, String postId, String discription) { + this.usernameFromToken = usernameFromToken; + this.postId = postId; + this.description = discription; + return addDescriptionResponse; + } + + @Override + public ResponseEntity addPostWithFile(InscriptionPostDto inscriptionPostDto, MultipartFile[] files, + String usernameFromToken) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getAllPost() { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getImages(String id) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getAllUserPost(String usernameFromToken) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getPostDiscription(String usernameFromToken) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity updatePostDiscription(String request, String postId, String discription) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity addRating(String usernameFromToken, String postId, Double rating) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity addVote(String usernameFromToken, String descriptionId) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity userProfile(String usernameFromToken) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity postDelete(String usernameFromToken, String postId) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity descriptionDelete(String usernameFromToken, String descriptionId) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity updatePost(String usernameFromToken, InscriptionPostDto inscriptionPostDto, + String postId, List deletedImageIds, MultipartFile[] files) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity addImagesToPost(String usernameFromToken, String postId, MultipartFile[] files) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity deleteImagesFromPost(String usernameFromToken, String postId, + List deletedImageIds) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getCommentByUser(String usernameFromToken) { + throw new UnsupportedOperationException(); + } + + @Override + public ResponseEntity getDashboardCounts() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportActionServiceTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportActionServiceTests.java new file mode 100644 index 0000000..03957c4 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/report/ReportActionServiceTests.java @@ -0,0 +1,335 @@ +package com.cadac.stone_inscription.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.lang.reflect.Proxy; +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; + +import com.cadac.stone_inscription.content.delete.ContentDeleteResult; +import com.cadac.stone_inscription.content.delete.ContentDeleteService; +import com.cadac.stone_inscription.entity.InscriptionPost; +import com.cadac.stone_inscription.entity.PublicPostDescription; +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.entity.enums.PostStatus; +import com.cadac.stone_inscription.entity.model.Report; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.service.ReportActionService; +import com.cadac.stone_inscription.repository.InscriptionPostRepo; +import com.cadac.stone_inscription.repository.PublicPostDescriptionRepo; +import com.cadac.stone_inscription.repository.UserRepository; + +class ReportActionServiceTests { + + @Test + void markTargetUnderReviewUpdatesPostStatusAndReporterEntry() { + SaveTracker postTracker = new SaveTracker<>(); + ReportActionService service = new ReportActionService( + postRepository(postTracker), + commentRepository(new SaveTracker<>(), null), + userRepository(null, new SaveTracker<>()), + new TrackingDeleteService()); + + InscriptionPost post = InscriptionPost.builder() + .id(new ObjectId()) + .userId(new ObjectId()) + .status(PostStatus.ACCEPTED) + .report(Report.builder().count(0).build()) + .build(); + + User reporter = User.builder().id(new ObjectId()).name("Alice").build(); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(post.getId().toHexString()) + .authorId(post.getUserId().toHexString()) + .type(ReportTargetType.POST) + .entity(post) + .content("spam") + .build(); + + service.markTargetUnderReview(target, reporter, "looks suspicious"); + + assertEquals(PostStatus.UNDER_REVIEW, post.getStatus()); + assertEquals(1, post.getReport().getReporters().size()); + assertEquals("looks suspicious", post.getReport().getReporters().get(0).getReason()); + assertEquals(post, postTracker.lastSaved); + } + + @Test + void warnActionRestoresAcceptanceAndIncrementsEmbeddedCount() { + SaveTracker postTracker = new SaveTracker<>(); + SaveTracker userTracker = new SaveTracker<>(); + ObjectId authorId = new ObjectId(); + User author = User.builder().id(authorId).reportCount(4).blackListed(false).build(); + + ReportActionService service = new ReportActionService( + postRepository(postTracker), + commentRepository(new SaveTracker<>(), null), + userRepository(author, userTracker), + new TrackingDeleteService()); + + InscriptionPost post = InscriptionPost.builder() + .id(new ObjectId()) + .userId(authorId) + .status(PostStatus.UNDER_REVIEW) + .report(Report.builder().count(2).build()) + .build(); + + service.applyAction( + baseReport(post.getId().toHexString(), authorId.toHexString(), ReportTargetType.POST), + ResolvedReportTarget.builder() + .id(post.getId().toHexString()) + .authorId(authorId.toHexString()) + .type(ReportTargetType.POST) + .entity(post) + .content("content") + .build(), + ModerationAction.WARN, + "moderator", + "first warning"); + + assertEquals(PostStatus.ACCEPTED, post.getStatus()); + assertEquals(3, post.getReport().getCount()); + assertEquals(5, author.getReportCount()); + assertEquals(post, postTracker.lastSaved); + assertEquals(author, userTracker.lastSaved); + } + + @Test + void removeContentActionIncrementsEmbeddedCommentCountAndDeletesComment() { + SaveTracker commentTracker = new SaveTracker<>(); + SaveTracker userTracker = new SaveTracker<>(); + TrackingDeleteService deleteService = new TrackingDeleteService(); + ObjectId authorId = new ObjectId(); + User author = User.builder().id(authorId).reportCount(0).blackListed(false).build(); + + ReportActionService service = new ReportActionService( + postRepository(new SaveTracker<>()), + commentRepository(commentTracker, null), + userRepository(author, userTracker), + deleteService); + + PublicPostDescription comment = PublicPostDescription.builder() + .id(new ObjectId()) + .userId(authorId) + .status(PostStatus.UNDER_REVIEW) + .report(Report.builder().count(0).build()) + .build(); + + service.applyAction( + baseReport(comment.getId().toHexString(), authorId.toHexString(), ReportTargetType.COMMENT), + ResolvedReportTarget.builder() + .id(comment.getId().toHexString()) + .authorId(authorId.toHexString()) + .type(ReportTargetType.COMMENT) + .entity(comment) + .content("abusive") + .build(), + ModerationAction.REMOVE_CONTENT, + "AI_MODERATOR", + null); + + assertEquals(1, comment.getReport().getCount()); + assertEquals(1, author.getReportCount()); + assertEquals(comment, commentTracker.lastSaved); + assertEquals(author, userTracker.lastSaved); + assertEquals(comment.getId(), deleteService.deletedCommentId); + } + + @Test + void dismissActionDoesNotIncrementEmbeddedCount() { + SaveTracker postTracker = new SaveTracker<>(); + ReportActionService service = new ReportActionService( + postRepository(postTracker), + commentRepository(new SaveTracker<>(), null), + userRepository(null, new SaveTracker<>()), + new TrackingDeleteService()); + + ObjectId authorId = new ObjectId(); + InscriptionPost post = InscriptionPost.builder() + .id(new ObjectId()) + .userId(authorId) + .status(PostStatus.UNDER_REVIEW) + .report(Report.builder().count(5).build()) + .build(); + + service.applyAction( + baseReport(post.getId().toHexString(), authorId.toHexString(), ReportTargetType.POST), + ResolvedReportTarget.builder() + .id(post.getId().toHexString()) + .authorId(authorId.toHexString()) + .type(ReportTargetType.POST) + .entity(post) + .content("normal") + .build(), + ModerationAction.DISMISS, + "moderator", + null); + + assertEquals(PostStatus.ACCEPTED, post.getStatus()); + assertEquals(5, post.getReport().getCount()); + assertEquals(post, postTracker.lastSaved); + } + + @Test + void userTargetRemoveContentDoesNotTriggerDeletion() { + TrackingDeleteService deleteService = new TrackingDeleteService(); + SaveTracker userTracker = new SaveTracker<>(); + ObjectId targetUserId = new ObjectId(); + User targetUser = User.builder().id(targetUserId).reportCount(1).blackListed(false).build(); + + ReportActionService service = new ReportActionService( + postRepository(new SaveTracker<>()), + commentRepository(new SaveTracker<>(), null), + userRepository(targetUser, userTracker), + deleteService); + + service.applyAction( + baseReport(targetUserId.toHexString(), targetUserId.toHexString(), ReportTargetType.USER), + ResolvedReportTarget.builder() + .id(targetUserId.toHexString()) + .authorId(targetUserId.toHexString()) + .type(ReportTargetType.USER) + .entity(targetUser) + .content("profile bio") + .build(), + ModerationAction.REMOVE_CONTENT, + "AI_MODERATOR", + null); + + assertEquals(2, targetUser.getReportCount()); + assertEquals(targetUser, userTracker.lastSaved); + assertNull(deleteService.deletedPostId); + assertNull(deleteService.deletedCommentId); + } + + private ModerationReport baseReport(String targetId, String targetAuthorId, ReportTargetType targetType) { + return ModerationReport.builder() + .id(new ObjectId()) + .reporterId(new ObjectId().toHexString()) + .targetId(targetId) + .targetType(targetType) + .targetAuthorId(targetAuthorId) + .reason(ReportReason.SPAM) + .details("details") + .status(ReportStatus.ESCALATED) + .actionTaken(ModerationAction.ESCALATE) + .build(); + } + + private InscriptionPostRepo postRepository(SaveTracker tracker) { + return (InscriptionPostRepo) Proxy.newProxyInstance( + InscriptionPostRepo.class.getClassLoader(), + new Class[] { InscriptionPostRepo.class }, + (proxy, method, args) -> { + if ("save".equals(method.getName())) { + tracker.lastSaved = (InscriptionPost) args[0]; + return args[0]; + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private PublicPostDescriptionRepo commentRepository( + SaveTracker tracker, + PublicPostDescription foundComment) { + return (PublicPostDescriptionRepo) Proxy.newProxyInstance( + PublicPostDescriptionRepo.class.getClassLoader(), + new Class[] { PublicPostDescriptionRepo.class }, + (proxy, method, args) -> { + if ("save".equals(method.getName())) { + tracker.lastSaved = (PublicPostDescription) args[0]; + return args[0]; + } + if ("findById".equals(method.getName())) { + return Optional.ofNullable(foundComment); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private UserRepository userRepository(User foundUser, SaveTracker tracker) { + return (UserRepository) Proxy.newProxyInstance( + UserRepository.class.getClassLoader(), + new Class[] { UserRepository.class }, + (proxy, method, args) -> { + if ("save".equals(method.getName())) { + tracker.lastSaved = (User) args[0]; + return args[0]; + } + if ("findById".equals(method.getName())) { + return Optional.ofNullable(foundUser); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private Object defaultValue(Class returnType) { + if (returnType.equals(boolean.class)) { + return false; + } + if (returnType.equals(int.class)) { + return 0; + } + if (returnType.equals(long.class)) { + return 0L; + } + if (returnType.equals(Optional.class)) { + return Optional.empty(); + } + if (java.util.Collection.class.isAssignableFrom(returnType)) { + return java.util.List.of(); + } + return null; + } + + private static class SaveTracker { + private T lastSaved; + } + + private static class TrackingDeleteService extends ContentDeleteService { + private ObjectId deletedPostId; + private ObjectId deletedCommentId; + + TrackingDeleteService() { + super(null, null, null, null, null, null, null); + } + + @Override + public ContentDeleteResult deletePost(ObjectId postId) { + deletedPostId = postId; + return ContentDeleteResult.builder().build(); + } + + @Override + public ContentDeleteResult deleteComment(ObjectId commentId) { + deletedCommentId = commentId; + return ContentDeleteResult.builder().build(); + } + } +} diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportControllerSecurityTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportControllerSecurityTests.java new file mode 100644 index 0000000..0e75a38 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/report/ReportControllerSecurityTests.java @@ -0,0 +1,40 @@ +package com.cadac.stone_inscription.report; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.annotation.Secured; + +import com.cadac.stone_inscription.report.controller.ReportController; + +class ReportControllerSecurityTests { + + @Test + void getReportsAllowsModeratorRoles() throws NoSuchMethodException { + Method method = ReportController.class.getMethod("getReports", jakarta.servlet.http.HttpServletRequest.class, + com.cadac.stone_inscription.report.enums.ReportStatus.class); + + Secured secured = method.getAnnotation(Secured.class); + + assertArrayEquals( + new String[] { "admin", "moderator", "human_moderator", "ai_moderator" }, + secured.value()); + } + + @Test + void moderateReportAllowsModeratorRoles() throws NoSuchMethodException { + Method method = ReportController.class.getMethod( + "moderateReport", + jakarta.servlet.http.HttpServletRequest.class, + String.class, + com.cadac.stone_inscription.report.dto.ModerateReportRequest.class); + + Secured secured = method.getAnnotation(Secured.class); + + assertArrayEquals( + new String[] { "admin", "moderator", "human_moderator", "ai_moderator" }, + secured.value()); + } +} diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java new file mode 100644 index 0000000..36bd9c0 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java @@ -0,0 +1,220 @@ +package com.cadac.stone_inscription.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.cadac.stone_inscription.report.moderation.AiModerationHandler; +import com.cadac.stone_inscription.report.moderation.HumanModerationHandler; +import com.cadac.stone_inscription.report.moderation.ModerationExecutionContext; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.service.ReportActionService; + +class ReportModerationTests { + + @Test + void moderationReportRejectsInvalidStateTransition() { + ModerationReport report = baseReport(); + + StoneInscriptionException exception = assertThrows( + StoneInscriptionException.class, + () -> report.transitionTo(ReportStatus.RESOLVED, "tester", ModerationAction.DISMISS, null)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getHttpStatus()); + } + + @Test + void aiHandlerAutoResolvesHighConfidenceReports() { + AiModerationHandler aiModerationHandler = new AiModerationHandler(); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ModerationReport report = baseReport(); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("This is obvious spam scam content") + .entity(new Object()) + .build(); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(target) + .actor(User.builder().id(new ObjectId()).build()) + .actorLabel("tester") + .requestedAction(null) + .note(null) + .reportActionService(reportActionService) + .build(); + + aiModerationHandler.handle(context); + + assertEquals(ReportStatus.RESOLVED, report.getStatus()); + assertEquals(ModerationAction.REMOVE_CONTENT, report.getActionTaken()); + assertEquals(1, reportActionService.invocations); + assertEquals(ModerationAction.REMOVE_CONTENT, reportActionService.lastAction); + } + + @Test + void aiHandlerEscalatesLowConfidenceReports() { + AiModerationHandler aiModerationHandler = new AiModerationHandler(); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ModerationReport report = baseReport(); + report.setReason(ReportReason.OTHER); + + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("Normal cooking discussion") + .entity(new Object()) + .build(); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(target) + .actor(User.builder().id(new ObjectId()).build()) + .actorLabel("tester") + .requestedAction(null) + .note(null) + .reportActionService(reportActionService) + .build(); + + aiModerationHandler.handle(context); + + assertEquals(ReportStatus.ESCALATED, report.getStatus()); + assertEquals(ModerationAction.ESCALATE, report.getActionTaken()); + assertEquals(0, reportActionService.invocations); + } + + @Test + void humanHandlerRejectsMissingActionForEscalatedReport() { + HumanModerationHandler humanModerationHandler = new HumanModerationHandler(); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ModerationReport report = baseReport(); + report.setStatus(ReportStatus.ESCALATED); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(baseTarget(new ObjectId().toHexString())) + .actor(User.builder().id(new ObjectId()).build()) + .actorLabel("moderator") + .requestedAction(null) + .reportActionService(reportActionService) + .build(); + + StoneInscriptionException exception = assertThrows( + StoneInscriptionException.class, + () -> humanModerationHandler.handle(context)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getHttpStatus()); + } + + @Test + void humanHandlerRejectsModeratingOwnContent() { + HumanModerationHandler humanModerationHandler = new HumanModerationHandler(); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ObjectId moderatorId = new ObjectId(); + ModerationReport report = baseReport(); + report.setStatus(ReportStatus.ESCALATED); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(baseTarget(moderatorId.toHexString())) + .actor(User.builder().id(moderatorId).build()) + .actorLabel("moderator") + .requestedAction(ModerationAction.DISMISS) + .reportActionService(reportActionService) + .build(); + + StoneInscriptionException exception = assertThrows( + StoneInscriptionException.class, + () -> humanModerationHandler.handle(context)); + + assertEquals(HttpStatus.BAD_REQUEST, exception.getHttpStatus()); + assertEquals(0, reportActionService.invocations); + } + + @Test + void humanHandlerResolvesEscalatedReportWithValidAction() { + HumanModerationHandler humanModerationHandler = new HumanModerationHandler(); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ModerationReport report = baseReport(); + report.setStatus(ReportStatus.ESCALATED); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(baseTarget(new ObjectId().toHexString())) + .actor(User.builder().id(new ObjectId()).build()) + .actorLabel("moderator") + .requestedAction(ModerationAction.BAN_AUTHOR) + .note("repeat offense") + .reportActionService(reportActionService) + .build(); + + humanModerationHandler.handle(context); + + assertEquals(ReportStatus.RESOLVED, report.getStatus()); + assertEquals(ModerationAction.BAN_AUTHOR, report.getActionTaken()); + assertEquals(1, reportActionService.invocations); + assertEquals(ModerationAction.BAN_AUTHOR, reportActionService.lastAction); + } + + private ModerationReport baseReport() { + return ModerationReport.builder() + .id(new ObjectId()) + .reporterId(new ObjectId().toHexString()) + .targetId(new ObjectId().toHexString()) + .targetType(ReportTargetType.POST) + .targetAuthorId(new ObjectId().toHexString()) + .reason(ReportReason.SPAM) + .details("Clearly spam") + .status(ReportStatus.PENDING) + .actionTaken(ModerationAction.NONE) + .build(); + } + + private ResolvedReportTarget baseTarget(String authorId) { + return ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(authorId) + .type(ReportTargetType.POST) + .content("Normal content") + .entity(new Object()) + .build(); + } + + private static class TrackingReportActionService extends ReportActionService { + private int invocations; + private ModerationAction lastAction; + + TrackingReportActionService() { + super(null, null, null, null); + } + + @Override + public void applyAction( + ModerationReport report, + ResolvedReportTarget target, + ModerationAction action, + String actor, + String note) { + invocations++; + lastAction = action; + } + } +} diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportSpecificationTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportSpecificationTests.java new file mode 100644 index 0000000..4d43905 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/report/ReportSpecificationTests.java @@ -0,0 +1,120 @@ +package com.cadac.stone_inscription.report; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.util.Collection; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.cadac.stone_inscription.report.repository.ModerationReportRepository; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.specification.NotDuplicateReportSpecification; +import com.cadac.stone_inscription.report.specification.NotSelfReportSpecification; +import com.cadac.stone_inscription.report.specification.ReportValidationContext; +import com.cadac.stone_inscription.report.specification.ReporterNotBlacklistedSpecification; + +class ReportSpecificationTests { + + @Test + void notSelfReportSpecRejectsOwnContent() { + ObjectId userId = new ObjectId(); + ReportValidationContext context = baseContext( + User.builder().id(userId).blackListed(false).build(), + target(userId.toHexString()), + repositoryReturning(false)); + + assertFalse(new NotSelfReportSpecification().isSatisfiedBy(context)); + } + + @Test + void notDuplicateReportSpecRejectsOpenDuplicate() { + User reporter = User.builder().id(new ObjectId()).blackListed(false).build(); + ReportValidationContext context = baseContext( + reporter, + target(new ObjectId().toHexString()), + repositoryReturning(true)); + + assertFalse(new NotDuplicateReportSpecification().isSatisfiedBy(context)); + } + + @Test + void reporterNotBlacklistedSpecRejectsBlacklistedReporter() { + ReportValidationContext context = baseContext( + User.builder().id(new ObjectId()).blackListed(true).build(), + target(new ObjectId().toHexString()), + repositoryReturning(false)); + + assertFalse(new ReporterNotBlacklistedSpecification().isSatisfiedBy(context)); + } + + @Test + void notDuplicateReportSpecAllowsResolvedDuplicate() { + User reporter = User.builder().id(new ObjectId()).blackListed(false).build(); + ReportValidationContext context = baseContext( + reporter, + target(new ObjectId().toHexString()), + repositoryReturning(false)); + + assertTrue(new NotDuplicateReportSpecification().isSatisfiedBy(context)); + } + + private ReportValidationContext baseContext( + User reporter, + ResolvedReportTarget target, + ModerationReportRepository repository) { + return ReportValidationContext.builder() + .reporter(reporter) + .target(target) + .reportRepository(repository) + .build(); + } + + private ResolvedReportTarget target(String authorId) { + return ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(authorId) + .type(ReportTargetType.POST) + .content("target content") + .entity(new Object()) + .build(); + } + + private ModerationReportRepository repositoryReturning(boolean duplicateExists) { + return (ModerationReportRepository) Proxy.newProxyInstance( + ModerationReportRepository.class.getClassLoader(), + new Class[] { ModerationReportRepository.class }, + (proxy, method, args) -> { + if ("existsByReporterIdAndTargetIdAndTargetTypeAndStatusIn".equals(method.getName())) { + return duplicateExists; + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + if ("toString".equals(method.getName())) { + return "ModerationReportRepositoryProxy"; + } + Class returnType = method.getReturnType(); + if (returnType.equals(boolean.class)) { + return false; + } + if (returnType.equals(int.class)) { + return 0; + } + if (returnType.equals(long.class)) { + return 0L; + } + if (Collection.class.isAssignableFrom(returnType)) { + return java.util.List.of(); + } + return null; + }); + } +} From 79d77987ed5b3ed1867fbb16b30646e57d8f662b Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Wed, 6 May 2026 17:35:41 +0530 Subject: [PATCH 2/2] Done with all Reporting system --- Reporting-API.md | 208 +++++++++++ .../post/service/PostServiceImp.java | 9 + .../report/service/ReportService.java | 44 ++- .../user/service/BlacklistGuardService.java | 25 ++ .../report/ReportServiceTests.java | 338 ++++++++++++++++++ 5 files changed, 610 insertions(+), 14 deletions(-) create mode 100644 Reporting-API.md create mode 100644 src/main/java/com/cadac/stone_inscription/user/service/BlacklistGuardService.java create mode 100644 src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java diff --git a/Reporting-API.md b/Reporting-API.md new file mode 100644 index 0000000..7a69bb3 --- /dev/null +++ b/Reporting-API.md @@ -0,0 +1,208 @@ +# Reporting API + +## Overview + +| Property | Value | +|---|---| +| **Base URL (Production)** | `https://inscriptions.cdacb.in/api` | +| **Authentication** | `Authorization: Bearer ` — required on all endpoints | + +--- + +## Endpoints at a Glance + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/report` | Submit a new report | +| `GET` | `/reports` | Fetch all reports | +| `POST` | `/moderate/{id}` | Moderate a specific report | + +--- + +## 1. `POST /report` + +> Submit a report against a Post, Comment, or User for moderation review. + +**Auth:** `Required` +**Roles:** `user` · `admin` + +### Request + +```http +POST https://inscriptions.cdacb.in/api/report +Authorization: Bearer +Content-Type: application/json +``` + +### Request Body + +```json +{ + "targetType": "POST", + "targetId": "6817a9d9f2b7b12c34d56789", + "reason": "SPAM", + "details": "This post is repeatedly promoting unrelated links." +} +``` + +### Fields + +| Field | Type | Required | Validation | +|---|---|---|---| +| `targetType` | `enum` | Yes | `POST` \| `COMMENT` \| `USER` | +| `targetId` | `string` | Yes | Must be a valid existing resource ID | +| `reason` | `enum` | Yes | See Reason Values below | +| `details` | `string` | Yes | Max 1000 characters | + +### `reason` Values + +| Value | Description | +|---|---| +| `SPAM` | Unsolicited or repetitive content | +| `HATE_SPEECH` | Content promoting hatred or discrimination | +| `MISINFORMATION` | False or misleading information | +| `HARASSMENT` | Targeting or bullying another user | +| `EXPLICIT_CONTENT` | Inappropriate or adult content | +| `OTHER` | Any other violation not listed above | + +### Examples + +**Report a Post for Spam** + +```json +{ + "targetType": "POST", + "targetId": "6817a9d9f2b7b12c34d56789", + "reason": "SPAM", + "details": "This post is repeatedly promoting unrelated links." +} +``` + +**Report a User for Harassment** + +```json +{ + "targetType": "USER", + "targetId": "6817a9d9f2b7b12c34d56000", + "reason": "HARASSMENT", + "details": "This user has been sending threatening messages to multiple members." +} +``` + +--- + +## 2. `GET /reports` + +> Fetch all moderation reports. Supports optional filtering by report status. + +**Auth:** `Required` +**Roles:** `admin` · `moderator` · `human_moderator` · `ai_moderator` + +### Request + +```http +GET https://inscriptions.cdacb.in/api/reports +Authorization: Bearer +``` + +### Query Parameters + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `status` | `enum` | No | Filter reports by status. Omit to return all reports. | + +### `status` Allowed Values + +| Value | Description | +|---|---| +| `PENDING` | Report filed but not yet picked up by AI screening | +| `AI_SCREENING` | AI is currently evaluating the report | +| `ESCALATED` | AI flagged it — waiting for a human moderator | +| `RESOLVED` | Final decision made, report is closed | + +### Example Requests + +```http +GET /reports +Authorization: Bearer +``` +Returns **all reports** regardless of status. + +```http +GET /reports?status=ESCALATED +Authorization: Bearer +``` +Returns only reports that are **waiting for human review**. + +```http +GET /reports?status=PENDING +Authorization: Bearer +``` +Returns reports that are **queued for AI screening**. + +--- + +## 3. `POST /moderate/{id}` + +> Moderate an escalated report by taking a moderation action on the reported content or user. + +**Auth:** `Required` +**Roles:** `admin` · `moderator` · `human_moderator` · `ai_moderator` + +### Request + +```http +POST https://inscriptions.cdacb.in/api/moderate/{id} +Authorization: Bearer +Content-Type: application/json +``` + +### Path Parameter + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `id` | `string` | Yes | The `_id` of the report to moderate (MongoDB ObjectId) | + +### Request Body + +```json +{ + "action": "REMOVE_CONTENT", + "note": "Content clearly violates community spam guidelines." +} +``` + +### Body Fields + +| Field | Type | Required | Validation | +|---|---|---|---| +| `action` | `enum` | **Yes** (for `ESCALATED` reports) | See Allowed Actions below | +| `note` | `string` | No | Moderator's reason or remarks. Max 1000 chars. | + +> **Important:** When the report status is `ESCALATED`, the `action` field is **required**. Omitting it or sending an invalid value will return `400 Bad Request`. + +### Allowed Actions + +> These are the only actions a human moderator can submit. The AI uses `ESCALATE` and `NONE` internally — do not pass those. + +| Action | What it does | +|---|---| +| `WARN` | Issues a warning to the content author. Content remains visible. | +| `REMOVE_CONTENT` | Deletes the reported post or comment. Increments author report count. | +| `BAN_AUTHOR` | Deletes the content and permanently bans the content author. | +| `BAN_REPORTER` | Blacklists the reporter (used when the report is false/abusive). Restores the reported content. | +| `DISMISS` | Dismisses the report as invalid. Restores the reported content. | + +### Notes + +- This endpoint is for **human moderation only**. It is called when a report has `status: ESCALATED` (i.e., the AI could not auto-resolve it). +- A moderator **cannot moderate their own content**. If the moderator's ID matches the content author's ID, the request will be rejected with `400`. +- The `action` field is **required for `ESCALATED` reports**. Only these values are accepted: `WARN`, `REMOVE_CONTENT`, `BAN_AUTHOR`, `BAN_REPORTER`, `DISMISS`. +- Every action is permanently recorded in `auditEntries` — this is the full audit trail for the report. +- **Side effects by action:** + - `REMOVE_CONTENT` — deletes the post or comment from the platform. + - `BAN_AUTHOR` — deletes the content and marks the author as blacklisted. + - `BAN_REPORTER` — blacklists the reporter and restores the reported content to `ACCEPTED` status. + - `DISMISS` — restores the reported content to `ACCEPTED` status, no penalty applied. + - `WARN` — restores the content and increments the author's report count. +- Once a report is `RESOLVED`, calling this endpoint again will return `400 Bad Request`. diff --git a/src/main/java/com/cadac/stone_inscription/post/service/PostServiceImp.java b/src/main/java/com/cadac/stone_inscription/post/service/PostServiceImp.java index 3b5d341..776b64a 100644 --- a/src/main/java/com/cadac/stone_inscription/post/service/PostServiceImp.java +++ b/src/main/java/com/cadac/stone_inscription/post/service/PostServiceImp.java @@ -41,6 +41,7 @@ import com.cadac.stone_inscription.repository.InscriptionPostRepo; import com.cadac.stone_inscription.repository.PublicPostDescriptionRepo; import com.cadac.stone_inscription.repository.UserRepository; +import com.cadac.stone_inscription.user.service.BlacklistGuardService; import com.cadac.stone_inscription.util.UserResponse; @Service @@ -69,6 +70,9 @@ public class PostServiceImp implements PostService { @Autowired private ContentDeleteService contentDeleteService; + @Autowired + private BlacklistGuardService blacklistGuardService; + @Value("${app.backend.url}") private String backendUrl; @@ -79,6 +83,7 @@ public ResponseEntity addPostWithFile(InscriptionPostDto inscriptionPostDto, String usernameFromToken) { User user = userRepository.findByEmail(usernameFromToken); + blacklistGuardService.ensureCanCreateOrModifyContent(user); List ls = validateAndExtractImages(files, user.getId(), Collections.emptySet(), true); // Below Line To use for Threshold similarty @@ -232,6 +237,7 @@ public ResponseEntity getAllUserPost(String usernameFromToken) { public ResponseEntity addPoastDiscription(String usernameFromToken, String postId, String discription) { User user = userRepository.findByEmail(usernameFromToken); + blacklistGuardService.ensureCanCreateOrModifyContent(user); InscriptionPost post = inscriptionPostRepo.findById(new ObjectId(postId)) .orElseThrow(() -> new StoneInscriptionException("Unprocesable request", HttpStatus.BAD_REQUEST)); @@ -255,6 +261,7 @@ public ResponseEntity getPostDiscription(String postId) { @Override public ResponseEntity updatePostDiscription(String usernameFromToken, String postId, String discription) { User user = userRepository.findByEmail(usernameFromToken); + blacklistGuardService.ensureCanCreateOrModifyContent(user); Optional postDiscription = publicPostDescriptionRepo.findById(new ObjectId(postId)); if (postDiscription.isEmpty()) { @@ -396,6 +403,7 @@ public ResponseEntity updatePost(String usernameFromToken, InscriptionPostDto InscriptionPost post = getOwnedPost(usernameFromToken, postId); User user = userRepository.findByEmail(usernameFromToken); + blacklistGuardService.ensureCanCreateOrModifyContent(user); List existingImageIds = getExistingImageIds(post); List imagesToDelete = validateDeletedImageIds(existingImageIds, deletedImageIds, false); Set deletableImageIds = new HashSet<>(imagesToDelete); @@ -441,6 +449,7 @@ public ResponseEntity updatePost(String usernameFromToken, InscriptionPostDto public ResponseEntity addImagesToPost(String usernameFromToken, String postId, MultipartFile[] files) { InscriptionPost post = getOwnedPost(usernameFromToken, postId); User user = userRepository.findByEmail(usernameFromToken); + blacklistGuardService.ensureCanCreateOrModifyContent(user); List newImages = validateAndExtractImages(files, user.getId(), Collections.emptySet(), true); List updatedImageIds = getExistingImageIds(post); diff --git a/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java b/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java index 8ce0a6c..665e373 100644 --- a/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java +++ b/src/main/java/com/cadac/stone_inscription/report/service/ReportService.java @@ -26,6 +26,7 @@ import com.cadac.stone_inscription.report.specification.Specification; import com.cadac.stone_inscription.repository.UserAuthRepository; import com.cadac.stone_inscription.repository.UserRepository; +import com.cadac.stone_inscription.user.service.BlacklistGuardService; import com.cadac.stone_inscription.util.UserResponse; import lombok.RequiredArgsConstructor; @@ -43,9 +44,11 @@ public class ReportService { private final AiModerationHandler aiModerationHandler; private final HumanModerationHandler humanModerationHandler; private final List> reportSpecifications; + private final BlacklistGuardService blacklistGuardService; public ResponseEntity createReport(String reporterEmail, CreateReportRequest request) { User reporter = getUserByEmail(reporterEmail); + blacklistGuardService.ensureCanReport(reporter); ResolvedReportTarget target = reportTargetResolver.resolve(request.getTargetType(), request.getTargetId()); validateReportRequest(reporter, target); @@ -53,8 +56,13 @@ public ResponseEntity createReport(String reporterEmail, CreateReportRequest ModerationReport report = reportFactory.create(reporter, target, request); moderationReportRepository.save(report); reportActionService.markTargetUnderReview(target, reporter, request.getDetails()); + moderateReportInternal(report, target, null, null); - return UserResponse.responseHandler("Report created successfully", HttpStatus.CREATED, report); + String message = report.getStatus() == ReportStatus.ESCALATED + ? "Report created and escalated for human moderation" + : "Report created and moderated successfully"; + + return UserResponse.responseHandler(message, HttpStatus.CREATED, report); } public ResponseEntity getReports(String requesterEmail, ReportStatus status) { @@ -82,19 +90,7 @@ public ResponseEntity moderateReport(String actorEmail, String reportId, Mode ensureModerator(actorEmail); } - ModerationHandler moderationPipeline = buildModerationPipeline(); - ModerationExecutionContext context = ModerationExecutionContext.builder() - .report(report) - .target(target) - .actor(actor) - .actorLabel(actor.getId().toHexString()) - .requestedAction(request == null ? null : request.getAction()) - .note(request == null ? null : request.getNote()) - .reportActionService(reportActionService) - .build(); - - moderationPipeline.handle(context); - moderationReportRepository.save(report); + moderateReportInternal(report, target, actor, request); String message = report.getStatus() == ReportStatus.ESCALATED ? "Report escalated for human moderation" @@ -125,6 +121,26 @@ private ModerationHandler buildModerationPipeline() { return aiModerationHandler; } + private void moderateReportInternal( + ModerationReport report, + ResolvedReportTarget target, + User actor, + ModerateReportRequest request) { + ModerationHandler moderationPipeline = buildModerationPipeline(); + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(target) + .actor(actor) + .actorLabel(actor == null || actor.getId() == null ? null : actor.getId().toHexString()) + .requestedAction(request == null ? null : request.getAction()) + .note(request == null ? null : request.getNote()) + .reportActionService(reportActionService) + .build(); + + moderationPipeline.handle(context); + moderationReportRepository.save(report); + } + private User getUserByEmail(String email) { User user = userRepository.findByEmail(email); if (user == null) { diff --git a/src/main/java/com/cadac/stone_inscription/user/service/BlacklistGuardService.java b/src/main/java/com/cadac/stone_inscription/user/service/BlacklistGuardService.java new file mode 100644 index 0000000..de38065 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/user/service/BlacklistGuardService.java @@ -0,0 +1,25 @@ +package com.cadac.stone_inscription.user.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.exception.StoneInscriptionException; + +@Service +public class BlacklistGuardService { + + public void ensureCanCreateOrModifyContent(User user) { + ensureNotBlacklisted(user, "Blacklisted users cannot create or modify content."); + } + + public void ensureCanReport(User user) { + ensureNotBlacklisted(user, "Blacklisted users cannot file reports."); + } + + private void ensureNotBlacklisted(User user, String message) { + if (user != null && Boolean.TRUE.equals(user.getBlackListed())) { + throw new StoneInscriptionException(message, HttpStatus.FORBIDDEN); + } + } +} diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java new file mode 100644 index 0000000..440b578 --- /dev/null +++ b/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java @@ -0,0 +1,338 @@ +package com.cadac.stone_inscription.report; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.cadac.stone_inscription.entity.User; +import com.cadac.stone_inscription.entity.UserAuth; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.report.dto.CreateReportRequest; +import com.cadac.stone_inscription.report.dto.ModerateReportRequest; +import com.cadac.stone_inscription.report.entity.ModerationReport; +import com.cadac.stone_inscription.report.enums.ModerationAction; +import com.cadac.stone_inscription.report.enums.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportStatus; +import com.cadac.stone_inscription.report.enums.ReportTargetType; +import com.cadac.stone_inscription.report.factory.ReportFactory; +import com.cadac.stone_inscription.report.moderation.AiModerationHandler; +import com.cadac.stone_inscription.report.moderation.HumanModerationHandler; +import com.cadac.stone_inscription.report.repository.ModerationReportRepository; +import com.cadac.stone_inscription.report.resolver.ReportTargetResolver; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.report.service.ReportActionService; +import com.cadac.stone_inscription.report.service.ReportService; +import com.cadac.stone_inscription.report.specification.Specification; +import com.cadac.stone_inscription.report.specification.ReportValidationContext; +import com.cadac.stone_inscription.repository.UserAuthRepository; +import com.cadac.stone_inscription.repository.UserRepository; +import com.cadac.stone_inscription.user.service.BlacklistGuardService; + +class ReportServiceTests { + + @Test + void createReportAutoModeratesHighConfidenceReports() { + User reporter = User.builder().id(new ObjectId()).email("reporter@example.com").name("Reporter").build(); + CreateReportRequest request = createRequest(ReportReason.SPAM, "obvious scam"); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("spam scam content") + .entity(new Object()) + .build(); + + TrackingReportActionService reportActionService = new TrackingReportActionService(); + ReportService reportService = new ReportService( + moderationReportRepository(null), + userRepository(reporter), + userAuthRepository(null), + new ReportFactory(), + new FixedTargetResolver(target), + reportActionService, + new AiModerationHandler(), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + ResponseEntity response = reportService.createReport(reporter.getEmail(), request); + + Map body = assertInstanceOf(Map.class, response.getBody()); + ModerationReport report = assertInstanceOf(ModerationReport.class, body.get("data")); + + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertEquals("Report created and moderated successfully", body.get("message")); + assertEquals(ReportStatus.RESOLVED, report.getStatus()); + assertEquals(ModerationAction.REMOVE_CONTENT, report.getActionTaken()); + assertEquals(1, reportActionService.markUnderReviewInvocations); + assertEquals(1, reportActionService.applyActionInvocations); + assertEquals(ModerationAction.REMOVE_CONTENT, reportActionService.lastAction); + assertEquals("AI_MODERATOR", reportActionService.lastActor); + } + + @Test + void createReportAutoEscalatesLowConfidenceReports() { + User reporter = User.builder().id(new ObjectId()).email("reporter@example.com").name("Reporter").build(); + CreateReportRequest request = createRequest(ReportReason.OTHER, "not sure"); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("harmless discussion") + .entity(new Object()) + .build(); + + TrackingReportActionService reportActionService = new TrackingReportActionService(); + ReportService reportService = new ReportService( + moderationReportRepository(null), + userRepository(reporter), + userAuthRepository(null), + new ReportFactory(), + new FixedTargetResolver(target), + reportActionService, + new AiModerationHandler(), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + ResponseEntity response = reportService.createReport(reporter.getEmail(), request); + + Map body = assertInstanceOf(Map.class, response.getBody()); + ModerationReport report = assertInstanceOf(ModerationReport.class, body.get("data")); + + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + assertEquals("Report created and escalated for human moderation", body.get("message")); + assertEquals(ReportStatus.ESCALATED, report.getStatus()); + assertEquals(ModerationAction.ESCALATE, report.getActionTaken()); + assertEquals(1, reportActionService.markUnderReviewInvocations); + assertEquals(0, reportActionService.applyActionInvocations); + } + + @Test + void moderateReportResolvesEscalatedReportWithModeratorAction() { + User moderator = User.builder().id(new ObjectId()).email("mod@example.com").build(); + UserAuth moderatorAuth = UserAuth.builder().email(moderator.getEmail()).roles(List.of("moderator")).build(); + ModerationReport report = ModerationReport.builder() + .id(new ObjectId()) + .reporterId(new ObjectId().toHexString()) + .targetId(new ObjectId().toHexString()) + .targetType(ReportTargetType.POST) + .targetAuthorId(new ObjectId().toHexString()) + .reason(ReportReason.OTHER) + .details("needs human review") + .status(ReportStatus.ESCALATED) + .actionTaken(ModerationAction.ESCALATE) + .build(); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(report.getTargetId()) + .authorId(report.getTargetAuthorId()) + .type(ReportTargetType.POST) + .content("normal content") + .entity(new Object()) + .build(); + ModerateReportRequest request = new ModerateReportRequest(); + request.setAction(ModerationAction.DISMISS); + request.setNote("false positive"); + + TrackingReportActionService reportActionService = new TrackingReportActionService(); + ReportService reportService = new ReportService( + moderationReportRepository(report), + userRepository(moderator), + userAuthRepository(moderatorAuth), + new ReportFactory(), + new FixedTargetResolver(target), + reportActionService, + new AiModerationHandler(), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + ResponseEntity response = reportService.moderateReport(moderator.getEmail(), report.getId().toHexString(), request); + + Map body = assertInstanceOf(Map.class, response.getBody()); + ModerationReport updatedReport = assertInstanceOf(ModerationReport.class, body.get("data")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("Report moderation completed", body.get("message")); + assertEquals(ReportStatus.RESOLVED, updatedReport.getStatus()); + assertEquals(ModerationAction.DISMISS, updatedReport.getActionTaken()); + assertEquals(1, reportActionService.applyActionInvocations); + assertEquals(ModerationAction.DISMISS, reportActionService.lastAction); + assertEquals(moderator.getId().toHexString(), reportActionService.lastActor); + assertEquals("false positive", reportActionService.lastNote); + } + + @Test + void createReportRejectsBlacklistedReporterBeforeModeration() { + User reporter = User.builder() + .id(new ObjectId()) + .email("reporter@example.com") + .name("Reporter") + .blackListed(true) + .build(); + CreateReportRequest request = createRequest(ReportReason.SPAM, "obvious scam"); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + ReportService reportService = new ReportService( + moderationReportRepository(null), + userRepository(reporter), + userAuthRepository(null), + new ReportFactory(), + new FixedTargetResolver(baseTarget()), + reportActionService, + new AiModerationHandler(), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + StoneInscriptionException exception = assertThrows( + StoneInscriptionException.class, + () -> reportService.createReport(reporter.getEmail(), request)); + + assertEquals(HttpStatus.FORBIDDEN, exception.getHttpStatus()); + assertEquals(0, reportActionService.markUnderReviewInvocations); + assertEquals(0, reportActionService.applyActionInvocations); + } + + private CreateReportRequest createRequest(ReportReason reason, String details) { + CreateReportRequest request = new CreateReportRequest(); + request.setTargetType(ReportTargetType.POST); + request.setTargetId(new ObjectId().toHexString()); + request.setReason(reason); + request.setDetails(details); + return request; + } + + private ResolvedReportTarget baseTarget() { + return ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("content") + .entity(new Object()) + .build(); + } + + private ModerationReportRepository moderationReportRepository(ModerationReport foundReport) { + return (ModerationReportRepository) Proxy.newProxyInstance( + ModerationReportRepository.class.getClassLoader(), + new Class[] { ModerationReportRepository.class }, + (proxy, method, args) -> { + if ("save".equals(method.getName())) { + return args[0]; + } + if ("findById".equals(method.getName())) { + return Optional.ofNullable(foundReport); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private UserRepository userRepository(User user) { + return (UserRepository) Proxy.newProxyInstance( + UserRepository.class.getClassLoader(), + new Class[] { UserRepository.class }, + (proxy, method, args) -> { + if ("findByEmail".equals(method.getName())) { + return user; + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private UserAuthRepository userAuthRepository(UserAuth userAuth) { + return (UserAuthRepository) Proxy.newProxyInstance( + UserAuthRepository.class.getClassLoader(), + new Class[] { UserAuthRepository.class }, + (proxy, method, args) -> { + if ("findByEmail".equals(method.getName())) { + return userAuth; + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private Object defaultValue(Class returnType) { + if (returnType == boolean.class) { + return false; + } + if (returnType == int.class) { + return 0; + } + if (returnType == long.class) { + return 0L; + } + return null; + } + + private static class FixedTargetResolver extends ReportTargetResolver { + private final ResolvedReportTarget target; + + FixedTargetResolver(ResolvedReportTarget target) { + super(null, null, null); + this.target = target; + } + + @Override + public ResolvedReportTarget resolve(ReportTargetType targetType, String targetId) { + return target; + } + } + + private static class TrackingReportActionService extends ReportActionService { + private int markUnderReviewInvocations; + private int applyActionInvocations; + private ModerationAction lastAction; + private String lastActor; + private String lastNote; + + TrackingReportActionService() { + super(null, null, null, null); + } + + @Override + public void markTargetUnderReview(ResolvedReportTarget target, User reporter, String details) { + markUnderReviewInvocations++; + } + + @Override + public void applyAction( + ModerationReport report, + ResolvedReportTarget target, + ModerationAction action, + String actor, + String note) { + applyActionInvocations++; + lastAction = action; + lastActor = actor; + lastNote = note; + } + } +}