From a8883188f22ca3ac5a30bab09c87cd585d3d7de0 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Tue, 5 May 2026 14:56:03 +0530 Subject: [PATCH 01/28] 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 02/28] 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; + } + } +} From 65ed4391422c031b8a1c6c0aa34753346885c704 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Tue, 19 May 2026 14:26:35 +0530 Subject: [PATCH 03/28] Done with the ReportSytem V2.0 --- .env | 12 + compose.yml | 60 +++- pom.xml | 8 +- .../admin/entity/AdminRequest.java | 80 +++++ .../admin/entity/AdminRequestStatus.java | 7 + .../repository/AdminRequestRepository.java | 17 + .../admin/service/AdminAccessService.java | 15 + .../admin/service/AdminAccessServiceImpl.java | 151 ++++++++ .../admin/service/AdminEmailService.java | 8 + .../admin/service/AdminEmailServiceImpl.java | 67 ++++ .../auth/CustomOAuth2SuccessHandler.java | 233 +++++++------ .../auth/OAuthFlowCookieService.java | 56 +++ .../stone_inscription/auth/OAuthFlowType.java | 31 ++ .../auth/controller/OAuthController.java | 140 ++++++-- .../auth/entity/RefreshToken.java | 17 +- .../auth/service/StoneAuthServiceImp.java | 20 +- .../kafka/config/KafkaConsumerConfig.java | 50 +++ .../kafka/config/KafkaProducerConfig.java | 40 +++ .../kafka/config/KafkaTopicConfig.java | 33 ++ .../consumer/base/BaseEventConsumer.java | 36 ++ .../report/ReportSubmittedConsumer.java | 43 +++ .../stone_inscription/kafka/dlt/DLTEvent.java | 39 +++ .../kafka/dlt/DLTHandler.java | 51 +++ .../kafka/dlt/DLTRepository.java | 10 + .../kafka/events/base/BaseEvent.java | 39 +++ .../events/report/ReportSubmittedEvent.java | 36 ++ .../exception/EventConsumeException.java | 20 ++ .../exception/EventPublishException.java | 15 + .../kafka/port/ReportServicePort.java | 6 + .../kafka/producer/BaseEventProducer.java | 39 +++ .../kafka/producer/ReportEventProducer.java | 25 ++ .../kafka/registry/TopicRegistry.java | 10 + .../post/controller/PostController.java | 24 +- .../adapter/ReportServiceKafkaAdapter.java | 21 ++ .../report/controller/ReportController.java | 6 +- .../report/entity/ModerationReport.java | 18 + .../report/factory/ReportFactory.java | 4 + .../moderation/AiModerationHandler.java | 326 +++++++++++++++++- .../ModerationReportRepository.java | 9 + .../report/service/ReportService.java | 185 +++++++++- .../service/ReportSubmissionService.java | 55 +++ src/main/resources/application.properties | 4 + src/main/resources/application.yml | 17 +- .../report/ReportModerationTests.java | 62 +++- .../report/ReportServiceTests.java | 172 ++++++++- 45 files changed, 2108 insertions(+), 209 deletions(-) create mode 100644 src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequest.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequestStatus.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/repository/AdminRequestRepository.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessService.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailService.java create mode 100644 src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailServiceImpl.java create mode 100644 src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java create mode 100644 src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/config/KafkaConsumerConfig.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/config/KafkaProducerConfig.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/config/KafkaTopicConfig.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/consumer/base/BaseEventConsumer.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/consumer/report/ReportSubmittedConsumer.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTEvent.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTHandler.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTRepository.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/events/base/BaseEvent.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/events/report/ReportSubmittedEvent.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/exception/EventConsumeException.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/exception/EventPublishException.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/port/ReportServicePort.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/producer/BaseEventProducer.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/producer/ReportEventProducer.java create mode 100644 src/main/java/com/cadac/stone_inscription/kafka/registry/TopicRegistry.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/adapter/ReportServiceKafkaAdapter.java create mode 100644 src/main/java/com/cadac/stone_inscription/report/service/ReportSubmissionService.java diff --git a/.env b/.env index 3f2de9f..5432865 100644 --- a/.env +++ b/.env @@ -27,4 +27,16 @@ CONTENT_MODERATION_INSECURE_SSL=true CONTENT_MODERATION_SAFE_THRESHOLD=0.7 CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 +ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com + +# Spring Mail +SPRING_MAIL_HOST=smtp.gmail.com +SPRING_MAIL_PORT=587 +SPRING_MAIL_USERNAME=your-email@example.com +SPRING_MAIL_PASSWORD=your-app-password +SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true +SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true # CONTENT_MODERATION_INSECURE_SSL=false + + +KAFKA_BOOTSTRAP_SERVERS=localhost:29092 diff --git a/compose.yml b/compose.yml index f743392..dd93968 100644 --- a/compose.yml +++ b/compose.yml @@ -1,9 +1,67 @@ services: + zookeeper: + image: zookeeper:3.9.5 + container_name: zookeeper + ports: + - "2181:2181" + + kafka: + image: confluentinc/cp-kafka:7.6.11 + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + - "29092:29092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_HOST://0.0.0.0:29092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + KAFKA_DELETE_TOPIC_ENABLE: "true" + + mongodb: + image: mongo:7.0 + container_name: mongodb + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + backend: container_name: backend - image: backend:latest + build: + context: . + dockerfile: Dockerfile + depends_on: + - kafka + - mongodb env_file: - .env + environment: + KAFKA_BOOTSTRAP_SERVERS: kafka:9092 + MONGO_URI: mongodb://mongodb:27017/StoneInscription ports: - "8080:8080" + akhq: + image: tchiotludo/akhq:0.25.1 + container_name: akhq + depends_on: + - kafka + ports: + - "8090:8080" + environment: + AKHQ_CONFIGURATION: | + akhq: + connections: + local-kafka: + properties: + bootstrap.servers: "kafka:9092" + +volumes: + mongodb_data: diff --git a/pom.xml b/pom.xml index 8474480..a6a6311 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,12 @@ spring-boot-starter-mail + + + org.springframework.kafka + spring-kafka + + org.projectlombok @@ -176,4 +182,4 @@ - \ No newline at end of file + diff --git a/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequest.java b/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequest.java new file mode 100644 index 0000000..eefdd53 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequest.java @@ -0,0 +1,80 @@ +package com.cadac.stone_inscription.admin.entity; + +import java.util.Date; + +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.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +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 = "admin_requests") +public class AdminRequest { + + @Id + @JsonProperty("_id") + @JsonSerialize(using = ToStringSerializer.class) + private ObjectId id; + + @Field("userAuthId") + @JsonProperty("userAuthId") + @JsonSerialize(using = ToStringSerializer.class) + private ObjectId userAuthId; + + @Field("email") + @JsonProperty("email") + @Indexed(unique = true) + private String email; + + @Field("name") + @JsonProperty("name") + private String name; + + @Field("provider") + @JsonProperty("provider") + private String provider; + + @Field("status") + @JsonProperty("status") + private AdminRequestStatus status; + + @Field("approvalTokenHash") + private String approvalTokenHash; + + @Field("approvalTokenExpiresAt") + @JsonProperty("approvalTokenExpiresAt") + private Date approvalTokenExpiresAt; + + @Field("approvedAt") + @JsonProperty("approvedAt") + private Date approvedAt; + + @Field("approvedBy") + @JsonProperty("approvedBy") + private String approvedBy; + + @CreatedDate + @Field("createdAt") + @JsonProperty("createdAt") + private Date createdAt; + + @LastModifiedDate + @Field("updatedAt") + @JsonProperty("updatedAt") + private Date updatedAt; +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequestStatus.java b/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequestStatus.java new file mode 100644 index 0000000..c44d238 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/entity/AdminRequestStatus.java @@ -0,0 +1,7 @@ +package com.cadac.stone_inscription.admin.entity; + +public enum AdminRequestStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/repository/AdminRequestRepository.java b/src/main/java/com/cadac/stone_inscription/admin/repository/AdminRequestRepository.java new file mode 100644 index 0000000..b8e55b2 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/repository/AdminRequestRepository.java @@ -0,0 +1,17 @@ +package com.cadac.stone_inscription.admin.repository; + +import java.util.Optional; + +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import com.cadac.stone_inscription.admin.entity.AdminRequest; + +@Repository +public interface AdminRequestRepository extends MongoRepository { + + Optional findByEmail(String email); + + Optional findByApprovalTokenHash(String approvalTokenHash); +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessService.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessService.java new file mode 100644 index 0000000..01c861d --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessService.java @@ -0,0 +1,15 @@ +package com.cadac.stone_inscription.admin.service; + +import com.cadac.stone_inscription.admin.entity.AdminRequest; +import com.cadac.stone_inscription.entity.UserAuth; + +public interface AdminAccessService { + + AdminRequest createOrRefreshPendingRequest(UserAuth userAuth, String name, String provider); + + boolean isApprovedAdmin(String email); + + void approveRequest(String rawToken); + + String getApprovalResultRedirectUrl(String status); +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java new file mode 100644 index 0000000..acc077a --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java @@ -0,0 +1,151 @@ +package com.cadac.stone_inscription.admin.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Date; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import com.cadac.stone_inscription.admin.entity.AdminRequest; +import com.cadac.stone_inscription.admin.entity.AdminRequestStatus; +import com.cadac.stone_inscription.admin.repository.AdminRequestRepository; +import com.cadac.stone_inscription.entity.UserAuth; +import com.cadac.stone_inscription.exception.StoneInscriptionException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminAccessServiceImpl implements AdminAccessService { + + private final AdminRequestRepository adminRequestRepository; + private final AdminEmailService adminEmailService; + + @Value("${app.backend.url}") + private String backendUrl; + + @Value("${app.frontend.admin.approval-result-url:https://inscriptions.cdacb.in/admin/approval-result}") + private String approvalResultUrl; + + @Value("${admin.approval.token-validity-ms:86400000}") + private long approvalTokenValidityMs; + + @Override + public AdminRequest createOrRefreshPendingRequest(UserAuth userAuth, String name, String provider) { + if (userAuth == null || userAuth.getEmail() == null) { + throw new StoneInscriptionException("User account is required for admin registration", HttpStatus.BAD_REQUEST); + } + + String rawToken = generateRawToken(); + String tokenHash = hashToken(rawToken); + Date expiresAt = new Date(System.currentTimeMillis() + approvalTokenValidityMs); + + AdminRequest request = adminRequestRepository.findByEmail(userAuth.getEmail()) + .map(existing -> updateExistingRequest(existing, userAuth, name, provider, tokenHash, expiresAt)) + .orElseGet(() -> buildNewRequest(userAuth, name, provider, tokenHash, expiresAt)); + + AdminRequest savedRequest = adminRequestRepository.save(request); + adminEmailService.sendApprovalRequest(savedRequest.getEmail(), savedRequest.getName(), buildApprovalLink(rawToken)); + return savedRequest; + } + + @Override + public boolean isApprovedAdmin(String email) { + return adminRequestRepository.findByEmail(email) + .map(request -> request.getStatus() == AdminRequestStatus.APPROVED) + .orElse(false); + } + + @Override + public void approveRequest(String rawToken) { + String tokenHash = hashToken(rawToken); + AdminRequest request = adminRequestRepository.findByApprovalTokenHash(tokenHash) + .orElseThrow(() -> new StoneInscriptionException("Invalid admin approval token", HttpStatus.BAD_REQUEST)); + + if (request.getApprovalTokenExpiresAt() == null || request.getApprovalTokenExpiresAt().before(new Date())) { + throw new StoneInscriptionException("Admin approval token expired", HttpStatus.BAD_REQUEST); + } + + request.setStatus(AdminRequestStatus.APPROVED); + request.setApprovedAt(new Date()); + request.setApprovedBy("email-link"); + request.setApprovalTokenHash(null); + request.setApprovalTokenExpiresAt(null); + + adminRequestRepository.save(request); + adminEmailService.sendApprovalConfirmed(request.getEmail(), request.getName()); + } + + public String getApprovalResultRedirectUrl(String status) { + return approvalResultUrl + "?status=" + status; + } + + private AdminRequest buildNewRequest( + UserAuth userAuth, + String name, + String provider, + String tokenHash, + Date expiresAt) { + + return AdminRequest.builder() + .userAuthId(userAuth.getId()) + .email(userAuth.getEmail()) + .name(name) + .provider(provider) + .status(AdminRequestStatus.PENDING) + .approvalTokenHash(tokenHash) + .approvalTokenExpiresAt(expiresAt) + .build(); + } + + private AdminRequest updateExistingRequest( + AdminRequest request, + UserAuth userAuth, + String name, + String provider, + String tokenHash, + Date expiresAt) { + + request.setUserAuthId(userAuth.getId()); + request.setEmail(userAuth.getEmail()); + request.setName(name); + request.setProvider(provider); + + if (request.getStatus() != AdminRequestStatus.APPROVED) { + request.setStatus(AdminRequestStatus.PENDING); + } + + request.setApprovalTokenHash(tokenHash); + request.setApprovalTokenExpiresAt(expiresAt); + return request; + } + + private String buildApprovalLink(String rawToken) { + return trimTrailingSlash(backendUrl) + "/oauth2/admin/approve?token=" + rawToken; + } + + private String trimTrailingSlash(String value) { + return value != null && value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + private String generateRawToken() { + byte[] bytes = new byte[32]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 algorithm not available", ex); + } + } +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailService.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailService.java new file mode 100644 index 0000000..3f1adbd --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailService.java @@ -0,0 +1,8 @@ +package com.cadac.stone_inscription.admin.service; + +public interface AdminEmailService { + + void sendApprovalRequest(String adminEmail, String adminName, String approvalLink); + + void sendApprovalConfirmed(String adminEmail, String adminName); +} diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailServiceImpl.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailServiceImpl.java new file mode 100644 index 0000000..a98d561 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminEmailServiceImpl.java @@ -0,0 +1,67 @@ +package com.cadac.stone_inscription.admin.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminEmailServiceImpl implements AdminEmailService { + + private final ObjectProvider mailSenderProvider; + + @Value("${ADMIN_APPROVAL_INTERNAL_EMAIL:}") + private String internalApprovalEmail; + + @Value("${spring.mail.username:}") + private String fromEmail; + + @Override + public void sendApprovalRequest(String adminEmail, String adminName, String approvalLink) { + if (internalApprovalEmail.isBlank()) { + throw new IllegalStateException("admin.approval.internal-email is not configured"); + } + JavaMailSender mailSender = getMailSender(); + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(internalApprovalEmail); + if (!fromEmail.isBlank()) { + message.setFrom(fromEmail); + } + message.setSubject("Admin approval request"); + message.setText( + "A new admin access request was submitted.\n\n" + + "Name: " + adminName + "\n" + + "Email: " + adminEmail + "\n\n" + + "Approve access using this link:\n" + + approvalLink); + mailSender.send(message); + } + + @Override + public void sendApprovalConfirmed(String adminEmail, String adminName) { + JavaMailSender mailSender = getMailSender(); + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(adminEmail); + if (!fromEmail.isBlank()) { + message.setFrom(fromEmail); + } + message.setSubject("Admin access approved"); + message.setText( + "Hello " + adminName + ",\n\n" + + "Your admin access request has been approved. " + + "You can now use Admin Login with your existing OAuth provider."); + mailSender.send(message); + } + + private JavaMailSender getMailSender() { + JavaMailSender mailSender = mailSenderProvider.getIfAvailable(); + if (mailSender == null) { + throw new IllegalStateException("Mail sender is not configured"); + } + return mailSender; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index a0749e7..2be91f0 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -1,26 +1,30 @@ package com.cadac.stone_inscription.auth; -import java.io.IOException; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -import org.bson.types.ObjectId; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import com.cadac.stone_inscription.auth.entity.RefreshToken; -import com.cadac.stone_inscription.auth.repository.RefreshTokenRepo; -import com.cadac.stone_inscription.auth.utill.GenrateRefreshToken; -import com.cadac.stone_inscription.entity.User; -import com.cadac.stone_inscription.entity.UserAuth; -import com.cadac.stone_inscription.repository.UserAuthRepository; -import com.cadac.stone_inscription.repository.UserRepository; +import java.io.IOException; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.admin.service.AdminAccessService; +import com.cadac.stone_inscription.auth.entity.RefreshToken; +import com.cadac.stone_inscription.auth.repository.RefreshTokenRepo; +import com.cadac.stone_inscription.auth.utill.GenrateRefreshToken; +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.repository.UserAuthRepository; +import com.cadac.stone_inscription.repository.UserRepository; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -29,19 +33,21 @@ @Component @RequiredArgsConstructor -public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final UserRepository userRepository; - private final UserAuthRepository userAuthRepository; - private final RefreshTokenRepo refreshTokenRepo; - - private static final String FRONTEND_CALLBACK_URL = - "https://inscriptions.cdacb.in/oauth/callback?status=success"; - - @Override - public void onAuthenticationSuccess( - HttpServletRequest request, - HttpServletResponse response, +public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final UserRepository userRepository; + private final UserAuthRepository userAuthRepository; + private final RefreshTokenRepo refreshTokenRepo; + private final AdminAccessService adminAccessService; + private final OAuthFlowCookieService oAuthFlowCookieService; + + @Value("${app.frontend.oauth.callback-url:https://inscriptions.cdacb.in/oauth/callback}") + private String frontendCallbackUrl; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, Authentication authentication ) throws IOException, ServletException { @@ -52,68 +58,97 @@ public void onAuthenticationSuccess( oauthToken.getPrincipal().getAttributes(); String provider = oauthToken.getAuthorizedClientRegistrationId(); - String email = (String) attributes.get("email"); - String name = (String) attributes.get("name"); - String picture = (String) attributes.get("picture"); - - // 1️⃣ Find or create user - UserAuth userAuth = userAuthRepository.findByEmail(email); - ObjectId userId; - - if (userAuth == null) { - UserAuth newUser = UserAuth.builder() - .email(email) - .provider(provider) - .passwordHash("oauth") - .roles(List.of("user")) - .build(); - - userAuth = userAuthRepository.save(newUser); - userId = userAuth.getId(); - - User profile = User.builder() - .name(name) - .email(email) - .profileImage(picture) - .active(true) - .authId(userId) - .build(); - - userRepository.save(profile); - } else { - userId = userAuth.getId(); - } - - // 2️⃣ Generate refresh token - String refreshToken = GenrateRefreshToken.doGenrateRefreshToken(); - - RefreshToken refreshTokenEntity = RefreshToken.builder() - .tokenHash(GenrateRefreshToken.hashRefreshToken(refreshToken)) - .userId(userId) - .createdAt(LocalDateTime.now()) - .lastUseAt(LocalDateTime.now()) - .expiresAt(LocalDateTime.now().plusDays(30)) - .revoke(false) - .build(); - - refreshTokenRepo.save(refreshTokenEntity); - - // 3️⃣ Set refresh token in HttpOnly cookie - ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) - .secure(true) // MUST be true in production (HTTPS) - .sameSite("None") // REQUIRED for OAuth cross-site redirect - .path("/") - .maxAge(Duration.ofDays(30)) - .build(); - - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - // 4️⃣ Redirect to frontend OAuth callback - getRedirectStrategy().sendRedirect( - request, - response, - FRONTEND_CALLBACK_URL - ); - } -} + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + String picture = (String) attributes.get("picture"); + OAuthFlowType flowType = oAuthFlowCookieService.readFlow(request); + + UserAuth userAuth = findOrCreateUser(email, name, picture, provider); + oAuthFlowCookieService.clearFlow(response); + + switch (flowType) { + case ADMIN_REGISTER -> { + adminAccessService.createOrRefreshPendingRequest(userAuth, name, provider); + redirect(request, response, "pending", "admin_register"); + } + case ADMIN_LOGIN -> { + if (!adminAccessService.isApprovedAdmin(email)) { + redirect(request, response, "denied", "admin_login"); + return; + } + issueRefreshCookie(response, userAuth.getId(), "admin"); + redirect(request, response, "success", "admin_login"); + } + case USER_LOGIN -> { + issueRefreshCookie(response, userAuth.getId(), "user"); + redirect(request, response, "success", "user_login"); + } + default -> throw new StoneInscriptionException("Unsupported OAuth flow", HttpStatus.BAD_REQUEST); + } + } + + private UserAuth findOrCreateUser(String email, String name, String picture, String provider) { + UserAuth userAuth = userAuthRepository.findByEmail(email); + if (userAuth != null) { + return userAuth; + } + + UserAuth newUser = UserAuth.builder() + .email(email) + .provider(provider) + .passwordHash("oauth") + .roles(List.of("user")) + .build(); + + UserAuth savedUserAuth = userAuthRepository.save(newUser); + ObjectId userId = savedUserAuth.getId(); + + User profile = User.builder() + .name(name) + .email(email) + .profileImage(picture) + .active(true) + .authId(userId) + .build(); + + userRepository.save(profile); + return savedUserAuth; + } + + private void issueRefreshCookie(HttpServletResponse response, ObjectId userId, String sessionRole) { + String refreshToken = GenrateRefreshToken.doGenrateRefreshToken(); + + RefreshToken refreshTokenEntity = RefreshToken.builder() + .tokenHash(GenrateRefreshToken.hashRefreshToken(refreshToken)) + .userId(userId) + .createdAt(LocalDateTime.now()) + .lastUseAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusDays(30)) + .revoke(false) + .sessionRole(sessionRole) + .build(); + + refreshTokenRepo.save(refreshTokenEntity); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ofDays(30)) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + } + + private void redirect( + HttpServletRequest request, + HttpServletResponse response, + String status, + String flow) throws IOException { + getRedirectStrategy().sendRedirect( + request, + response, + frontendCallbackUrl + "?status=" + status + "&flow=" + flow); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java new file mode 100644 index 0000000..f41acdf --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java @@ -0,0 +1,56 @@ +package com.cadac.stone_inscription.auth; + +import java.time.Duration; +import java.util.Arrays; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class OAuthFlowCookieService { + + public static final String FLOW_COOKIE_NAME = "oauth_flow"; + + public void storeFlow(HttpServletResponse response, OAuthFlowType flowType) { + ResponseCookie cookie = ResponseCookie.from(FLOW_COOKIE_NAME, flowType.getValue()) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ofMinutes(10)) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + public OAuthFlowType readFlow(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return OAuthFlowType.USER_LOGIN; + } + + return Arrays.stream(cookies) + .filter(cookie -> FLOW_COOKIE_NAME.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .map(OAuthFlowType::fromValue) + .orElse(OAuthFlowType.USER_LOGIN); + } + + public void clearFlow(HttpServletResponse response) { + ResponseCookie cookie = ResponseCookie.from(FLOW_COOKIE_NAME, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ZERO) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java new file mode 100644 index 0000000..e3fb526 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java @@ -0,0 +1,31 @@ +package com.cadac.stone_inscription.auth; + +public enum OAuthFlowType { + USER_LOGIN("user_login"), + ADMIN_REGISTER("admin_register"), + ADMIN_LOGIN("admin_login"); + + private final String value; + + OAuthFlowType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static OAuthFlowType fromValue(String value) { + if (value == null || value.isBlank()) { + return USER_LOGIN; + } + + for (OAuthFlowType type : values()) { + if (type.value.equalsIgnoreCase(value)) { + return type; + } + } + + return USER_LOGIN; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index 3a10b25..d83ad5b 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -1,43 +1,56 @@ package com.cadac.stone_inscription.auth.controller; -import java.io.IOException; -import java.util.Arrays; -import java.util.Map; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.cadac.stone_inscription.auth.JwtUtil; -import com.cadac.stone_inscription.auth.service.StoneAuthService; -import com.cadac.stone_inscription.exception.StoneInscriptionException; -import com.nimbusds.jose.JOSEException; +import java.io.IOException; +import java.util.Arrays; + +import org.springframework.beans.factory.annotation.Value; +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.RequestParam; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.cadac.stone_inscription.admin.service.AdminAccessService; +import com.cadac.stone_inscription.auth.OAuthFlowCookieService; +import com.cadac.stone_inscription.auth.OAuthFlowType; +import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.auth.service.StoneAuthService; +import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.nimbusds.jose.JOSEException; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.web.csrf.CsrfToken; - -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping("/oauth2") -public class OAuthController { - - @Autowired - StoneAuthService stoneAuthService; - - - @PostMapping("/logout") - public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { - - return stoneAuthService.logoutAuth(request, response); +public class OAuthController { + + @Autowired + StoneAuthService stoneAuthService; + + @Autowired + private OAuthFlowCookieService oAuthFlowCookieService; + + @Autowired + private AdminAccessService adminAccessService; + + @Value("${app.oauth2.default-provider:google}") + private String defaultProvider; + + + @PostMapping("/logout") + public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { + + return stoneAuthService.logoutAuth(request, response); } @PostMapping("/authenticated/refresh-token") @@ -63,13 +76,64 @@ public ResponseEntity updateLastActive(HttpServletRequest request) { if (refreshToken == null) { throw new StoneInscriptionException("Invalid Request No token found", HttpStatus.BAD_REQUEST); } - - return stoneAuthService.updateLastActive(refreshToken); - } - - - // @Autowired - // private JwtUtil jwtUtil; + + return stoneAuthService.updateLastActive(refreshToken); + } + + @GetMapping("/login/{provider}") + public void loginWithProvider( + @PathVariable String provider, + HttpServletResponse response) throws IOException { + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.USER_LOGIN); + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/login") + public void login(HttpServletResponse response) throws IOException { + loginWithProvider(defaultProvider, response); + } + + @GetMapping("/admin/register/{provider}") + public void adminRegister( + @PathVariable String provider, + HttpServletResponse response) throws IOException { + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_REGISTER); + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/admin/register") + public void adminRegisterDefault(HttpServletResponse response) throws IOException { + adminRegister(defaultProvider, response); + } + + @GetMapping("/admin/login/{provider}") + public void adminLogin( + @PathVariable String provider, + HttpServletResponse response) throws IOException { + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_LOGIN); + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/admin/login") + public void adminLoginDefault(HttpServletResponse response) throws IOException { + adminLogin(defaultProvider, response); + } + + @GetMapping("/admin/approve") + public void approveAdminRequest( + @RequestParam("token") String token, + HttpServletResponse response) throws IOException { + try { + adminAccessService.approveRequest(token); + response.sendRedirect(adminAccessService.getApprovalResultRedirectUrl("approved")); + } catch (StoneInscriptionException ex) { + response.sendRedirect(adminAccessService.getApprovalResultRedirectUrl("failed")); + } + } + + + // @Autowired + // private JwtUtil jwtUtil; // @GetMapping("/google") // public void loginWithGoogle(HttpServletResponse response) throws IOException diff --git a/src/main/java/com/cadac/stone_inscription/auth/entity/RefreshToken.java b/src/main/java/com/cadac/stone_inscription/auth/entity/RefreshToken.java index 3864e44..3e6f9d2 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/entity/RefreshToken.java +++ b/src/main/java/com/cadac/stone_inscription/auth/entity/RefreshToken.java @@ -11,7 +11,7 @@ @Document(collection = "t_Refresh_Token") @Builder @Data -public class RefreshToken { +public class RefreshToken { @Id private String id; @@ -19,10 +19,11 @@ public class RefreshToken { private ObjectId userId; private LocalDateTime expiresAt; private LocalDateTime lastUseAt; - private LocalDateTime createdAt; - private Boolean revoke; - // private String deviceId; - // private String ipAddress; - // private String userAgent; - -} \ No newline at end of file + private LocalDateTime createdAt; + private Boolean revoke; + private String sessionRole; + // private String deviceId; + // private String ipAddress; + // private String userAgent; + +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/service/StoneAuthServiceImp.java b/src/main/java/com/cadac/stone_inscription/auth/service/StoneAuthServiceImp.java index 2e28b71..f4fda70 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/service/StoneAuthServiceImp.java +++ b/src/main/java/com/cadac/stone_inscription/auth/service/StoneAuthServiceImp.java @@ -133,8 +133,11 @@ public ResponseEntity refreshToken(HttpServletRequest request, HttpServletRes String refreshTokenRotate = GenrateRefreshToken.doGenrateRefreshToken(); User user = userRepository.findByAuthId(refreshTokenobj.getUserId()); - String role = "user"; - String accessToken = jwtUtil.generateToken(userDetailsService.loadUserByUsername(user.getEmail()), role); + String role = refreshTokenobj.getSessionRole(); + if (role == null || role.isBlank()) { + role = "user"; + } + String accessToken = jwtUtil.generateToken(userDetailsService.loadUserByUsername(user.getEmail()), role); // Object user = userRepo.findById(refreshTokenobj.getUserId().toString()); @@ -153,12 +156,13 @@ public ResponseEntity refreshToken(HttpServletRequest request, HttpServletRes // jwtUtil.generateToken(userDetailsService.loadUserByUsername(((SchoolRegistration) // user).getUsername()), roles); - RefreshToken rotationObj = RefreshToken.builder().createdAt(LocalDateTime.now()) - .expiresAt(LocalDateTime.now().plusDays(30)) - .lastUseAt(LocalDateTime.now()).revoke(false) - .tokenHash(GenrateRefreshToken.hashRefreshToken(refreshTokenRotate)) - .userId(refreshTokenobj.getUserId()) - .build(); + RefreshToken rotationObj = RefreshToken.builder().createdAt(LocalDateTime.now()) + .expiresAt(LocalDateTime.now().plusDays(30)) + .lastUseAt(LocalDateTime.now()).revoke(false) + .tokenHash(GenrateRefreshToken.hashRefreshToken(refreshTokenRotate)) + .userId(refreshTokenobj.getUserId()) + .sessionRole(role) + .build(); refreshTokenRepo.save(rotationObj); diff --git a/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaConsumerConfig.java b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaConsumerConfig.java new file mode 100644 index 0000000..76ef76f --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaConsumerConfig.java @@ -0,0 +1,50 @@ +package com.cadac.stone_inscription.kafka.config; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.DefaultKafkaConsumerFactory; +import org.springframework.kafka.support.serializer.JsonDeserializer; + + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id:stone-inscription-group}") + private String consumerGroupId; + + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + config.put(JsonDeserializer.TRUSTED_PACKAGES, + "com.cadac.stone_inscription.kafka.events,com.cadac.stone_inscription.kafka.events.*"); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, true); + config.put(JsonDeserializer.REMOVE_TYPE_INFO_HEADERS, false); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + + return new DefaultKafkaConsumerFactory<>(config); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + return factory; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaProducerConfig.java b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaProducerConfig.java new file mode 100644 index 0000000..0f69332 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaProducerConfig.java @@ -0,0 +1,40 @@ +package com.cadac.stone_inscription.kafka.config; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + + +@Configuration +public class KafkaProducerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(ProducerConfig.ACKS_CONFIG, "all"); + config.put(ProducerConfig.RETRIES_CONFIG, 3); + config.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + + return new DefaultKafkaProducerFactory<>(config); + } + + @Bean + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaTopicConfig.java b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaTopicConfig.java new file mode 100644 index 0000000..804f456 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/config/KafkaTopicConfig.java @@ -0,0 +1,33 @@ +package com.cadac.stone_inscription.kafka.config; + +import java.util.concurrent.TimeUnit; + +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.common.config.TopicConfig; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +import com.cadac.stone_inscription.kafka.registry.TopicRegistry; + +@Configuration +public class KafkaTopicConfig { + + @Bean + public NewTopic reportSubmittedTopic() { + return TopicBuilder.name(TopicRegistry.REPORT_SUBMITTED) + .partitions(12) + .replicas(1) + .config(TopicConfig.RETENTION_MS_CONFIG, + String.valueOf(TimeUnit.DAYS.toMillis(7))) + .build(); + } + + @Bean + public NewTopic reportSubmittedDltTopic() { + return TopicBuilder.name(TopicRegistry.REPORT_SUBMITTED_DLT) + .partitions(1) + .replicas(1) + .build(); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/consumer/base/BaseEventConsumer.java b/src/main/java/com/cadac/stone_inscription/kafka/consumer/base/BaseEventConsumer.java new file mode 100644 index 0000000..eeb3963 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/consumer/base/BaseEventConsumer.java @@ -0,0 +1,36 @@ +package com.cadac.stone_inscription.kafka.consumer.base; + +import java.util.function.Consumer; + +import com.cadac.stone_inscription.kafka.dlt.DLTHandler; +import com.cadac.stone_inscription.kafka.events.base.BaseEvent; +import com.cadac.stone_inscription.kafka.exception.EventConsumeException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public abstract class BaseEventConsumer { + + private final DLTHandler dltHandler; + + protected BaseEventConsumer(DLTHandler dltHandler) { + this.dltHandler = dltHandler; + } + + protected void consume(String topic, + String key, + T event, + Consumer handler) { + try { + log.info("[CONSUMER] Received | topic={} key={}", topic, key); + handler.accept(event); + log.info("[CONSUMER] Processed | topic={} key={}", topic, key); + } catch (Exception ex) { + log.error("[CONSUMER] Failed | topic={} key={} reason={}", + topic, key, ex.getMessage()); + dltHandler.handle(topic, key, event, ex); + String eventId = event instanceof BaseEvent baseEvent ? baseEvent.getEventId() : key; + throw new EventConsumeException(topic, eventId, "Failed to consume event", ex); + } + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/consumer/report/ReportSubmittedConsumer.java b/src/main/java/com/cadac/stone_inscription/kafka/consumer/report/ReportSubmittedConsumer.java new file mode 100644 index 0000000..9b58057 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/consumer/report/ReportSubmittedConsumer.java @@ -0,0 +1,43 @@ +package com.cadac.stone_inscription.kafka.consumer.report; + +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.kafka.consumer.base.BaseEventConsumer; +import com.cadac.stone_inscription.kafka.dlt.DLTHandler; +import com.cadac.stone_inscription.kafka.events.report.ReportSubmittedEvent; +import com.cadac.stone_inscription.kafka.port.ReportServicePort; +import com.cadac.stone_inscription.kafka.registry.TopicRegistry; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class ReportSubmittedConsumer extends BaseEventConsumer { + + private final ReportServicePort reportServicePort; + + public ReportSubmittedConsumer(DLTHandler dltHandler, + ReportServicePort reportServicePort) { + super(dltHandler); + this.reportServicePort = reportServicePort; + } + + @KafkaListener( + topics = TopicRegistry.REPORT_SUBMITTED, + groupId = "report-submitted-group", + containerFactory = "kafkaListenerContainerFactory" + ) + public void onReportSubmitted(@Payload ReportSubmittedEvent event, + @Header(KafkaHeaders.RECEIVED_KEY) String key) { + consume( + TopicRegistry.REPORT_SUBMITTED, + key, + event, + reportServicePort::createReport + ); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTEvent.java b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTEvent.java new file mode 100644 index 0000000..3c53426 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTEvent.java @@ -0,0 +1,39 @@ +package com.cadac.stone_inscription.kafka.dlt; + +import java.time.Instant; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "dead_letter_logs") +public class DLTEvent { + @Id + private String id; + private String topic; + private String key; + private String payload; + private String failureReason; + private Instant timestamp; + + public DLTEvent() { + } + + public DLTEvent(String topic, + String key, + String payload, + String failureReason, + Instant timestamp) { + this.topic = topic; + this.key = key; + this.payload = payload; + this.failureReason = failureReason; + this.timestamp = timestamp; + } + + public String getId() { return id; } + public String getTopic() { return topic; } + public String getKey() { return key; } + public String getPayload() { return payload; } + public String getFailureReason() { return failureReason; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTHandler.java b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTHandler.java new file mode 100644 index 0000000..5cbfab1 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTHandler.java @@ -0,0 +1,51 @@ +package com.cadac.stone_inscription.kafka.dlt; + +import java.time.Instant; + +import org.springframework.stereotype.Component; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class DLTHandler { + + private final DLTRepository dltRepository; + private final ObjectMapper objectMapper; + + public DLTHandler(DLTRepository dltRepository, + ObjectMapper objectMapper) { + this.dltRepository = dltRepository; + this.objectMapper = objectMapper; + } + + public void handle(String topic, + String key, + Object payload, + Throwable cause) { + + String serialized = serialize(payload); + + DLTEvent dltEvent = new DLTEvent( + topic, + key, + serialized, + cause.getMessage(), + Instant.now() + ); + + dltRepository.save(dltEvent); + + log.error("[DLT] Failed event stored | topic={} key={} reason={} timestamp={}", + topic, key, cause.getMessage(), dltEvent.getTimestamp()); + } + + private String serialize(Object payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + return payload.toString(); + } + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTRepository.java b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTRepository.java new file mode 100644 index 0000000..13f228a --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/dlt/DLTRepository.java @@ -0,0 +1,10 @@ +package com.cadac.stone_inscription.kafka.dlt; + +import java.util.List; + +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface DLTRepository extends MongoRepository { + List findByTopic(String topic); + List findByKey(String key); +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/events/base/BaseEvent.java b/src/main/java/com/cadac/stone_inscription/kafka/events/base/BaseEvent.java new file mode 100644 index 0000000..c04db97 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/events/base/BaseEvent.java @@ -0,0 +1,39 @@ +package com.cadac.stone_inscription.kafka.events.base; + +import java.time.Instant; +import java.util.UUID; + +public abstract class BaseEvent { + + private String eventId; + private String source; + private String version; + private Instant timestamp; + + protected BaseEvent() { + this.eventId = UUID.randomUUID().toString(); + this.timestamp = Instant.now(); + } + + protected BaseEvent(String source, String version) { + this(); + this.source = source; + this.version = version; + } + + public String getEventId() { + return eventId; + } + + public String getSource() { + return source; + } + + public String getVersion() { + return version; + } + + public Instant getTimestamp() { + return timestamp; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/events/report/ReportSubmittedEvent.java b/src/main/java/com/cadac/stone_inscription/kafka/events/report/ReportSubmittedEvent.java new file mode 100644 index 0000000..714f527 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/events/report/ReportSubmittedEvent.java @@ -0,0 +1,36 @@ +package com.cadac.stone_inscription.kafka.events.report; + +import com.cadac.stone_inscription.kafka.events.base.BaseEvent; + +public class ReportSubmittedEvent extends BaseEvent { + private String reporterId; + private String targetType; + private String targetId; + private String reason; + private String description; + + public ReportSubmittedEvent() { + super(); + } + + public ReportSubmittedEvent(String source, + String version, + String reporterId, + String targetId, + String targetType, + String reason, + String description) { + super(source, version); + this.reporterId = reporterId; + this.reason = reason; + this.targetType = targetType; + this.description = description; + this.targetId = targetId; + } + + public String getReporterId() { return reporterId; } + public String getReason() { return reason; } + public String getTargetId() { return targetId; } + public String getTargetType() { return targetType; } + public String getDescription() { return description; } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/exception/EventConsumeException.java b/src/main/java/com/cadac/stone_inscription/kafka/exception/EventConsumeException.java new file mode 100644 index 0000000..0491ea7 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/exception/EventConsumeException.java @@ -0,0 +1,20 @@ +package com.cadac.stone_inscription.kafka.exception; + +public class EventConsumeException extends RuntimeException { + private final String topic; + private final String eventId; + + public EventConsumeException(String topic, String eventId, String message, Throwable cause) { + super(message, cause); + this.topic = topic; + this.eventId = eventId; + } + + public String getTopic() { + return topic; + } + + public String getEventId() { + return eventId; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/exception/EventPublishException.java b/src/main/java/com/cadac/stone_inscription/kafka/exception/EventPublishException.java new file mode 100644 index 0000000..b9c23b3 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/exception/EventPublishException.java @@ -0,0 +1,15 @@ +package com.cadac.stone_inscription.kafka.exception; + +public class EventPublishException extends RuntimeException{ + + private final String topic; + private final String key; + public EventPublishException(String topic,String key,String message,Throwable cause){ + super(message,cause); + this.topic = topic; + this.key = key; + } + + public String getTopic() {return topic;} + public String getKey() {return key;} +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/port/ReportServicePort.java b/src/main/java/com/cadac/stone_inscription/kafka/port/ReportServicePort.java new file mode 100644 index 0000000..674c4a9 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/port/ReportServicePort.java @@ -0,0 +1,6 @@ +package com.cadac.stone_inscription.kafka.port; +import com.cadac.stone_inscription.kafka.events.report.ReportSubmittedEvent; + +public interface ReportServicePort { + void createReport(ReportSubmittedEvent event); +} \ No newline at end of file diff --git a/src/main/java/com/cadac/stone_inscription/kafka/producer/BaseEventProducer.java b/src/main/java/com/cadac/stone_inscription/kafka/producer/BaseEventProducer.java new file mode 100644 index 0000000..6404506 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/producer/BaseEventProducer.java @@ -0,0 +1,39 @@ +package com.cadac.stone_inscription.kafka.producer; + +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.kafka.dlt.DLTHandler; +import com.cadac.stone_inscription.kafka.exception.EventPublishException; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public abstract class BaseEventProducer { + + private final KafkaTemplate kafkaTemplate; + private final DLTHandler dltHandler; + + protected BaseEventProducer(KafkaTemplate kafkaTemplate, + DLTHandler dltHandler) { + this.kafkaTemplate = kafkaTemplate; + this.dltHandler = dltHandler; + } + + protected void send(String topic, String key, T event) { + kafkaTemplate.send(topic, key, event) + .whenComplete((result, ex) -> { + if (ex != null) { + log.error("[PRODUCER] Failed | topic={} key={} reason={}", + topic, key, ex.getMessage()); + dltHandler.handle(topic, key, event, ex); + throw new EventPublishException(topic, key, + "Failed to publish event", ex); + } + log.info("[PRODUCER] Success | topic={} key={} offset={}", + topic, key, + result.getRecordMetadata().offset()); + }); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/producer/ReportEventProducer.java b/src/main/java/com/cadac/stone_inscription/kafka/producer/ReportEventProducer.java new file mode 100644 index 0000000..0e616f9 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/producer/ReportEventProducer.java @@ -0,0 +1,25 @@ +package com.cadac.stone_inscription.kafka.producer; + +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.kafka.dlt.DLTHandler; +import com.cadac.stone_inscription.kafka.events.report.ReportSubmittedEvent; +import com.cadac.stone_inscription.kafka.registry.TopicRegistry; + +@Component +public class ReportEventProducer extends BaseEventProducer { + + public ReportEventProducer(KafkaTemplate kafkaTemplate, + DLTHandler dltHandler) { + super(kafkaTemplate, dltHandler); + } + + public void publishReportSubmitted(ReportSubmittedEvent event) { + String key = event.getReporterId() + + ":" + event.getTargetType() + + ":" + event.getTargetId(); + + send(TopicRegistry.REPORT_SUBMITTED, key, event); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/kafka/registry/TopicRegistry.java b/src/main/java/com/cadac/stone_inscription/kafka/registry/TopicRegistry.java new file mode 100644 index 0000000..5eb10b5 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/kafka/registry/TopicRegistry.java @@ -0,0 +1,10 @@ +package com.cadac.stone_inscription.kafka.registry; + +public final class TopicRegistry { + private TopicRegistry() { + } + + public static final String REPORT_SUBMITTED = "report.submitted"; + + public static final String REPORT_SUBMITTED_DLT = "report.submitted.DLT"; +} 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 fa1a245..320f251 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 @@ -278,22 +278,22 @@ public ResponseEntity deleteImagesFromPost(HttpServletRequest request, // return postService.updatePost(email, InscriptionPostDto, postId, deletedImageIds, files); // } - // @PostMapping("/test/addPostWithFile/{email}") - // public ResponseEntity addPostWithFileForTest( - // @PathVariable String email, - // @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, - // @RequestPart("files") MultipartFile... files) throws IOException { + @PostMapping("/test/addPostWithFile/{email}") + public ResponseEntity addPostWithFileForTest( + @PathVariable String email, + @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, + @RequestPart("files") MultipartFile... files) throws IOException { - // files = getNonEmptyFiles(files); + files = getNonEmptyFiles(files); - // if (files.length == 0) { - // throw new StoneInscriptionException("No File Uploaded", HttpStatus.BAD_REQUEST); - // } + if (files.length == 0) { + throw new StoneInscriptionException("No File Uploaded", HttpStatus.BAD_REQUEST); + } - // validateFiles(files, MAX_IMAGES_PER_POST); + validateFiles(files, MAX_IMAGES_PER_POST); - // return postService.addPostWithFile(InscriptionPostDto, files, email); - // } + return postService.addPostWithFile(InscriptionPostDto, files, email); + } // @PostMapping("/test/addPoastDiscription/{email}") // public ResponseEntity addPoastDiscriptionForTest( diff --git a/src/main/java/com/cadac/stone_inscription/report/adapter/ReportServiceKafkaAdapter.java b/src/main/java/com/cadac/stone_inscription/report/adapter/ReportServiceKafkaAdapter.java new file mode 100644 index 0000000..e527240 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/adapter/ReportServiceKafkaAdapter.java @@ -0,0 +1,21 @@ +package com.cadac.stone_inscription.report.adapter; + +import org.springframework.stereotype.Component; + +import com.cadac.stone_inscription.kafka.events.report.ReportSubmittedEvent; +import com.cadac.stone_inscription.kafka.port.ReportServicePort; +import com.cadac.stone_inscription.report.service.ReportService; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ReportServiceKafkaAdapter implements ReportServicePort { + + private final ReportService reportService; + + @Override + public void createReport(ReportSubmittedEvent event) { + reportService.runAiModerationForSubmittedEvent(event); + } +} 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 index 501cd9e..34d6723 100644 --- a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java +++ b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java @@ -16,6 +16,7 @@ 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 com.cadac.stone_inscription.report.service.ReportSubmissionService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -26,6 +27,7 @@ public class ReportController { private final ReportService reportService; + private final ReportSubmissionService reportSubmissionService; private final JwtUtil jwtUtil; @PostMapping("/report") @@ -34,7 +36,7 @@ public ResponseEntity createReport( HttpServletRequest request, @Valid @RequestBody CreateReportRequest createReportRequest) { - return reportService.createReport(extractEmailFromToken(request), createReportRequest); + return reportSubmissionService.submitReport(extractEmailFromToken(request), createReportRequest); } @PostMapping("/test/report/{email}") @@ -42,7 +44,7 @@ public ResponseEntity createReportForTest( @PathVariable String email, @Valid @RequestBody CreateReportRequest createReportRequest) { - return reportService.createReport(email, createReportRequest); + return reportSubmissionService.submitReport(email, createReportRequest); } @GetMapping("/reports") 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 index 290e8f6..b4fa165 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java @@ -11,6 +11,7 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.Id; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Version; import org.springframework.data.mongodb.core.index.CompoundIndex; import org.springframework.data.mongodb.core.index.CompoundIndexes; import org.springframework.data.mongodb.core.index.Indexed; @@ -79,6 +80,11 @@ public class ModerationReport { @Indexed private ReportStatus status; + @Field("activeReportKey") + @JsonProperty("activeReportKey") + @Indexed(unique = true, sparse = true) + private String activeReportKey; + @Field("actionTaken") @JsonProperty("actionTaken") @Builder.Default @@ -107,11 +113,20 @@ public class ModerationReport { @JsonProperty("resolvedAt") private Date resolvedAt; + @Version + @Field("version") + @JsonProperty("version") + private Long version; + @Field("auditEntries") @JsonProperty("auditEntries") @Builder.Default private List auditEntries = new ArrayList<>(); + public static String buildActiveReportKey(String reporterId, String targetId, ReportTargetType targetType) { + return reporterId + ":" + targetType + ":" + targetId; + } + public void addAuditEntry(String actor, String message) { auditEntries.add(ReportAuditEntry.builder() .actor(actor) @@ -133,6 +148,9 @@ public void transitionTo(ReportStatus newStatus, String actor, ModerationAction if (newStatus == ReportStatus.RESOLVED) { this.resolvedAt = new Date(); + this.activeReportKey = null; + } else if (this.activeReportKey == null && reporterId != null && targetId != null && targetType != null) { + this.activeReportKey = buildActiveReportKey(reporterId, targetId, targetType); } StringBuilder builder = new StringBuilder() 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 index 4a56c25..99b8145 100644 --- a/src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java +++ b/src/main/java/com/cadac/stone_inscription/report/factory/ReportFactory.java @@ -23,6 +23,10 @@ public ModerationReport create(User reporter, ResolvedReportTarget target, Creat .reason(request.getReason()) .details(request.getDetails().trim()) .status(ReportStatus.PENDING) + .activeReportKey(ModerationReport.buildActiveReportKey( + reporter.getId().toHexString(), + target.getId(), + target.getType())) .actionTaken(ModerationAction.NONE) .aiConfidenceScore(0.0) .build(); 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 index 1574264..c7566e3 100644 --- a/src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java +++ b/src/main/java/com/cadac/stone_inscription/report/moderation/AiModerationHandler.java @@ -1,20 +1,64 @@ package com.cadac.stone_inscription.report.moderation; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +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.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.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; @Component public class AiModerationHandler extends ModerationHandler { + private static final Logger log = LoggerFactory.getLogger(AiModerationHandler.class); private static final String AI_ACTOR = "AI_MODERATOR"; private static final double AUTO_RESOLVE_THRESHOLD = 0.85; + private static final int CONNECT_TIMEOUT_MS = (int) Duration.ofSeconds(5).toMillis(); + private static final int READ_TIMEOUT_MS = (int) Duration.ofSeconds(10).toMillis(); + private static final List RESPONSE_WRAPPER_FIELDS = List.of( + "data", + "result", + "results", + "response", + "responses", + "body", + "payload", + "output", + "outputs", + "json"); + + private final RestTemplate restTemplate = buildRestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${ai.moderation.webhook-url}") + private String aiModerationWebhookUrl; @Override public void handle(ModerationExecutionContext context) { @@ -26,7 +70,11 @@ public void handle(ModerationExecutionContext context) { report.transitionTo(ReportStatus.AI_SCREENING, AI_ACTOR, ModerationAction.NONE, context.getNote()); - double score = computeConfidenceScore(context); + Double score = computeConfidenceScore(context); + if (score == null) { + return; + } + report.setAiConfidenceScore(score, AI_ACTOR); if (score >= AUTO_RESOLVE_THRESHOLD) { @@ -39,27 +87,141 @@ public void handle(ModerationExecutionContext context) { report.transitionTo(ReportStatus.ESCALATED, AI_ACTOR, ModerationAction.ESCALATE, context.getNote()); } - private double computeConfidenceScore(ModerationExecutionContext context) { + 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; - }; + if (!isWebhookConfigured()) { + log.warn("AI moderation webhook URL is not configured for report {}", report.getId()); + return null; + } - List flaggedTerms = List.of("hate", "spam", "scam", "fake", "explicit", "kill", "abuse"); - boolean hasFlaggedTerms = flaggedTerms.stream().anyMatch(combinedText::contains); + Map requestBody = buildWebhookRequestBody(report, context.getTarget()); - if (hasFlaggedTerms) { - base += 0.20; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + try { + String rawResponse = invokeWebhook(requestBody, headers); + return extractConfidenceScore(rawResponse); + } catch (RestClientException ex) { + log.warn("AI moderation webhook call failed for report {}: {}", + report.getId(), ex.getMessage()); + return null; } + } + + protected String invokeWebhook(Map requestBody, HttpHeaders headers) { + return restTemplate.postForObject( + aiModerationWebhookUrl, + new HttpEntity<>(requestBody, headers), + String.class); + } - return Math.min(base, 1.0); + protected boolean isWebhookConfigured() { + return !isBlank(aiModerationWebhookUrl); + } + + private Map buildWebhookRequestBody(ModerationReport report, ResolvedReportTarget target) { + Map requestBody = new LinkedHashMap<>(); + String content = target == null ? null : target.getContent(); + String details = report.getDetails(); + String combinedReportReason = buildReportReason(report.getReason(), details); + + requestBody.put("title", extractTitle(target)); + requestBody.put("topic", extractTopic(target)); + requestBody.put("description", content); + requestBody.put("source", isBlank(details) ? content : details); + requestBody.put("report_reason", combinedReportReason); + + // Keep the original fields for compatibility with any downstream consumers. + requestBody.put("reportId", report.getId() == null ? null : report.getId().toHexString()); + requestBody.put("reason", report.getReason() == null ? null : report.getReason().name()); + requestBody.put("content", content); + requestBody.put("details", details); + + return requestBody; + } + + private String extractTitle(ResolvedReportTarget target) { + if (target == null || target.getEntity() == null) { + return fallbackTitle(target); + } + + Object entity = target.getEntity(); + if (entity instanceof InscriptionPost post) { + String title = post.getDescription() == null ? null : post.getDescription().getTitle(); + return isBlank(title) ? fallbackTitle(target) : title; + } + + if (entity instanceof User user) { + return isBlank(user.getName()) ? fallbackTitle(target) : user.getName(); + } + + return fallbackTitle(target); + } + + private String extractTopic(ResolvedReportTarget target) { + if (target == null || target.getEntity() == null) { + return fallbackTopic(target); + } + + Object entity = target.getEntity(); + if (entity instanceof InscriptionPost post) { + if (!isBlank(post.getTopic())) { + return post.getTopic(); + } + + String subject = post.getDescription() == null ? null : post.getDescription().getSubject(); + return isBlank(subject) ? fallbackTopic(target) : subject; + } + + if (entity instanceof PublicPostDescription) { + return "comment"; + } + + if (entity instanceof User) { + return "user"; + } + + return fallbackTopic(target); + } + + private String fallbackTitle(ResolvedReportTarget target) { + ReportTargetType targetType = target == null ? null : target.getType(); + if (targetType == ReportTargetType.COMMENT) { + return "Reported comment"; + } + + if (targetType == ReportTargetType.USER) { + return "Reported user"; + } + + return "Reported content"; + } + + private String fallbackTopic(ResolvedReportTarget target) { + ReportTargetType targetType = target == null ? null : target.getType(); + if (targetType == null) { + return "report"; + } + + return targetType.name().toLowerCase(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String buildReportReason(ReportReason reason, String details) { + String reasonValue = reason == null ? null : reason.name(); + if (isBlank(reasonValue)) { + return details; + } + + if (isBlank(details)) { + return reasonValue; + } + + return reasonValue + " - " + details.trim(); } private ModerationAction determineAutoAction(ReportReason reason) { @@ -68,4 +230,132 @@ private ModerationAction determineAutoAction(ReportReason reason) { case OTHER -> ModerationAction.ESCALATE; }; } + + private RestTemplate buildRestTemplate() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(CONNECT_TIMEOUT_MS); + requestFactory.setReadTimeout(READ_TIMEOUT_MS); + return new RestTemplate(requestFactory); + } + + protected Double extractConfidenceScore(String rawResponse) { + if (rawResponse == null || rawResponse.isBlank()) { + log.warn("AI moderation webhook returned blank response"); + return null; + } + + try { + JsonNode root = objectMapper.readTree(rawResponse); + AiModerationResponse response = extractModerationResponse(root); + if (response == null || response.getConfidenceScore() == null) { + log.warn("AI moderation response missing confidence field: {}", rawResponse); + return null; + } + + return response.getConfidenceScore(); + } catch (IOException ex) { + log.warn("Failed to parse AI moderation response: {}", rawResponse, ex); + return null; + } + } + + private AiModerationResponse extractModerationResponse(JsonNode node) { + if (node == null || node.isNull() || node.isMissingNode()) { + return null; + } + + if (node.isArray()) { + for (JsonNode child : node) { + AiModerationResponse response = extractModerationResponse(child); + if (response != null) { + return response; + } + } + return null; + } + + if (!node.isObject()) { + return null; + } + + List wrappedResponses = extractWrappedResponses(node); + if (!wrappedResponses.isEmpty()) { + return wrappedResponses.get(0); + } + + if (looksLikeModerationNode(node)) { + return objectMapper.convertValue(node, AiModerationResponse.class); + } + + return null; + } + + private List extractWrappedResponses(JsonNode node) { + List responses = new ArrayList<>(); + + for (String field : RESPONSE_WRAPPER_FIELDS) { + JsonNode wrappedNode = node.get(field); + if (wrappedNode != null) { + AiModerationResponse response = extractModerationResponse(wrappedNode); + if (response != null) { + responses.add(response); + } + } + } + + if (!responses.isEmpty()) { + return responses; + } + + for (Map.Entry entry : iterable(node.fields())) { + if (RESPONSE_WRAPPER_FIELDS.contains(entry.getKey())) { + continue; + } + + AiModerationResponse response = extractModerationResponse(entry.getValue()); + if (response != null) { + responses.add(response); + return responses; + } + } + + return responses; + } + + private boolean looksLikeModerationNode(JsonNode node) { + return hasAny(node, + "confidence", "score", "confidenceScore", "confidence_score", "probability", + "decision", "verdict", "action", + "status", "state", + "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(Iterator iterator) { + return () -> iterator; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static class AiModerationResponse { + @JsonProperty("confidence") + @JsonAlias({ "score", "confidenceScore", "confidence_score", "probability" }) + private Double confidenceScore; + + public Double getConfidenceScore() { + return confidenceScore; + } + + public void setConfidenceScore(Double confidenceScore) { + this.confidenceScore = confidenceScore; + } + } } 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 index 141894a..4f88b65 100644 --- a/src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java +++ b/src/main/java/com/cadac/stone_inscription/report/repository/ModerationReportRepository.java @@ -2,6 +2,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; import org.bson.types.ObjectId; import org.springframework.data.mongodb.repository.MongoRepository; @@ -23,4 +24,12 @@ boolean existsByReporterIdAndTargetIdAndTargetTypeAndStatusIn( String targetId, ReportTargetType targetType, Collection statuses); + + Optional findFirstByActiveReportKey(String activeReportKey); + + Optional findFirstByReporterIdAndTargetIdAndTargetTypeAndStatusInOrderByCreatedAtDesc( + String reporterId, + String targetId, + ReportTargetType targetType, + Collection statuses); } 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 665e373..5a7e2f6 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 @@ -1,8 +1,11 @@ package com.cadac.stone_inscription.report.service; import java.util.List; +import java.util.Optional; import org.bson.types.ObjectId; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; @@ -10,9 +13,12 @@ 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.kafka.events.report.ReportSubmittedEvent; 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.ReportReason; +import com.cadac.stone_inscription.report.enums.ReportTargetType; import com.cadac.stone_inscription.report.enums.ReportStatus; import com.cadac.stone_inscription.report.factory.ReportFactory; import com.cadac.stone_inscription.report.moderation.AiModerationHandler; @@ -46,25 +52,31 @@ public class ReportService { private final List> reportSpecifications; private final BlacklistGuardService blacklistGuardService; - public ResponseEntity createReport(String reporterEmail, CreateReportRequest request) { - User reporter = getUserByEmail(reporterEmail); + public ResponseEntity submitLegacySynchronousReport(String reporterEmail, CreateReportRequest request) { + User reporter = getUserByEmailOrThrow(reporterEmail); blacklistGuardService.ensureCanReport(reporter); ResolvedReportTarget target = reportTargetResolver.resolve(request.getTargetType(), request.getTargetId()); validateReportRequest(reporter, target); ModerationReport report = reportFactory.create(reporter, target, request); - moderationReportRepository.save(report); + saveNewReport(report); reportActionService.markTargetUnderReview(target, reporter, request.getDetails()); - moderateReportInternal(report, target, null, null); + report = moderateReportInternal(report, target, null, null); - String message = report.getStatus() == ReportStatus.ESCALATED - ? "Report created and escalated for human moderation" - : "Report created and moderated successfully"; + String message = switch (report.getStatus()) { + case ESCALATED -> "Report created and escalated for human moderation"; + case AI_SCREENING -> "Report created and awaiting AI moderation"; + default -> "Report created and moderated successfully"; + }; return UserResponse.responseHandler(message, HttpStatus.CREATED, report); } + public ResponseEntity createReport(String reporterEmail, CreateReportRequest request) { + return submitLegacySynchronousReport(reporterEmail, request); + } + public ResponseEntity getReports(String requesterEmail, ReportStatus status) { ensureModerator(requesterEmail); @@ -76,7 +88,7 @@ public ResponseEntity getReports(String requesterEmail, ReportStatus status) } public ResponseEntity moderateReport(String actorEmail, String reportId, ModerateReportRequest request) { - User actor = getUserByEmail(actorEmail); + User actor = getUserByEmailOrThrow(actorEmail); ModerationReport report = moderationReportRepository.findById(parseObjectId(reportId)) .orElseThrow(() -> new StoneInscriptionException("Report not found", HttpStatus.NOT_FOUND)); @@ -90,15 +102,66 @@ public ResponseEntity moderateReport(String actorEmail, String reportId, Mode ensureModerator(actorEmail); } - moderateReportInternal(report, target, actor, request); + report = moderateReportInternal(report, target, actor, request); - String message = report.getStatus() == ReportStatus.ESCALATED - ? "Report escalated for human moderation" - : "Report moderation completed"; + String message = switch (report.getStatus()) { + case ESCALATED -> "Report escalated for human moderation"; + case AI_SCREENING -> "Report remains in AI screening"; + default -> "Report moderation completed"; + }; return UserResponse.responseHandler(message, HttpStatus.OK, report); } + public ResolvedReportTarget validateSubmission(User reporter, CreateReportRequest request) { + blacklistGuardService.ensureCanReport(reporter); + ResolvedReportTarget target = reportTargetResolver.resolve(request.getTargetType(), request.getTargetId()); + validateReportRequest(reporter, target); + return target; + } + + public ModerationReport createOrGetReportFromEvent(ReportSubmittedEvent event) { + ReportTargetType targetType = parseTargetType(event.getTargetType()); + String activeReportKey = ModerationReport.buildActiveReportKey( + event.getReporterId(), + event.getTargetId(), + targetType); + + Optional existingReport = moderationReportRepository.findFirstByActiveReportKey(activeReportKey); + + if (existingReport.isPresent()) { + return existingReport.get(); + } + + User reporter = getUserByIdOrThrow(event.getReporterId()); + ResolvedReportTarget target = reportTargetResolver.resolve(targetType, event.getTargetId()); + CreateReportRequest request = buildCreateReportRequest(event); + validateSubmission(reporter, request); + + ModerationReport report = reportFactory.create(reporter, target, request); + return saveNewReportOrGetExisting(report, target, reporter, request.getDetails()); + } + + public String runAiModerationForSubmittedEvent(ReportSubmittedEvent event) { + ModerationReport report = createOrGetReportFromEvent(event); + + if (report.getStatus() == ReportStatus.RESOLVED) { + return null; + } + + if (report.getStatus() == ReportStatus.ESCALATED) { + return report.getId().toHexString(); + } + + ResolvedReportTarget target = reportTargetResolver.resolve(report.getTargetType(), report.getTargetId()); + moderateAiOnly(report, target, "Queued AI moderation review"); + report = saveReportOrReloadLatest(report); + + return report.getStatus() == ReportStatus.ESCALATED + ? report.getId().toHexString() + : null; + } + private void validateReportRequest(User reporter, ResolvedReportTarget target) { ReportValidationContext context = ReportValidationContext.builder() .reporter(reporter) @@ -116,12 +179,26 @@ private void validateReportRequest(User reporter, ResolvedReportTarget target) { } } + private void moderateAiOnly(ModerationReport report, ResolvedReportTarget target, String note) { + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(target) + .actor(null) + .actorLabel("AI_MODERATOR") + .requestedAction(null) + .note(note) + .reportActionService(reportActionService) + .build(); + + aiModerationHandler.handle(context); + } + private ModerationHandler buildModerationPipeline() { aiModerationHandler.setNext(humanModerationHandler); return aiModerationHandler; } - private void moderateReportInternal( + private ModerationReport moderateReportInternal( ModerationReport report, ResolvedReportTarget target, User actor, @@ -138,10 +215,53 @@ private void moderateReportInternal( .build(); moderationPipeline.handle(context); - moderationReportRepository.save(report); + return saveReportWithConflictHandling(report); + } + + private void saveNewReport(ModerationReport report) { + try { + moderationReportRepository.save(report); + } catch (DuplicateKeyException ex) { + throw new StoneInscriptionException("You have already reported this target.", HttpStatus.BAD_REQUEST); + } + } + + private ModerationReport saveNewReportOrGetExisting( + ModerationReport report, + ResolvedReportTarget target, + User reporter, + String details) { + try { + moderationReportRepository.save(report); + reportActionService.markTargetUnderReview(target, reporter, details); + return report; + } catch (DuplicateKeyException ex) { + return moderationReportRepository.findFirstByActiveReportKey(report.getActiveReportKey()) + .orElseThrow(() -> new StoneInscriptionException( + "Concurrent report creation failed to reload the active report.", + HttpStatus.CONFLICT)); + } } - private User getUserByEmail(String email) { + private ModerationReport saveReportWithConflictHandling(ModerationReport report) { + try { + return moderationReportRepository.save(report); + } catch (OptimisticLockingFailureException ex) { + throw new StoneInscriptionException( + "Report was updated by another moderation flow. Please reload and try again.", + HttpStatus.CONFLICT); + } + } + + private ModerationReport saveReportOrReloadLatest(ModerationReport report) { + try { + return moderationReportRepository.save(report); + } catch (OptimisticLockingFailureException ex) { + return getReportByIdOrThrow(report.getId().toHexString()); + } + } + + public User getUserByEmailOrThrow(String email) { User user = userRepository.findByEmail(email); if (user == null) { throw new StoneInscriptionException("User not found", HttpStatus.NOT_FOUND); @@ -149,6 +269,16 @@ private User getUserByEmail(String email) { return user; } + private User getUserByIdOrThrow(String userId) { + return userRepository.findById(parseObjectId(userId)) + .orElseThrow(() -> new StoneInscriptionException("User not found", HttpStatus.NOT_FOUND)); + } + + private ModerationReport getReportByIdOrThrow(String reportId) { + return moderationReportRepository.findById(parseObjectId(reportId)) + .orElseThrow(() -> new StoneInscriptionException("Report not found", HttpStatus.NOT_FOUND)); + } + private void ensureModerator(String email) { UserAuth userAuth = userAuthRepository.findByEmail(email); if (userAuth == null || userAuth.getRoles() == null) { @@ -167,6 +297,31 @@ private void ensureModerator(String email) { } } + private CreateReportRequest buildCreateReportRequest(ReportSubmittedEvent event) { + CreateReportRequest request = new CreateReportRequest(); + request.setTargetType(parseTargetType(event.getTargetType())); + request.setTargetId(event.getTargetId()); + request.setReason(parseReason(event.getReason())); + request.setDetails(event.getDescription()); + return request; + } + + private ReportTargetType parseTargetType(String rawTargetType) { + try { + return ReportTargetType.valueOf(rawTargetType); + } catch (IllegalArgumentException ex) { + throw new StoneInscriptionException("Invalid target type", HttpStatus.BAD_REQUEST); + } + } + + private ReportReason parseReason(String rawReason) { + try { + return ReportReason.valueOf(rawReason); + } catch (IllegalArgumentException ex) { + throw new StoneInscriptionException("Invalid report reason", HttpStatus.BAD_REQUEST); + } + } + private ObjectId parseObjectId(String id) { if (!ObjectId.isValid(id)) { throw new StoneInscriptionException("Invalid report id", HttpStatus.BAD_REQUEST); diff --git a/src/main/java/com/cadac/stone_inscription/report/service/ReportSubmissionService.java b/src/main/java/com/cadac/stone_inscription/report/service/ReportSubmissionService.java new file mode 100644 index 0000000..4947244 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/report/service/ReportSubmissionService.java @@ -0,0 +1,55 @@ +package com.cadac.stone_inscription.report.service; + +import java.util.Map; + +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.kafka.events.report.ReportSubmittedEvent; +import com.cadac.stone_inscription.kafka.producer.ReportEventProducer; +import com.cadac.stone_inscription.report.dto.CreateReportRequest; +import com.cadac.stone_inscription.report.resolver.ResolvedReportTarget; +import com.cadac.stone_inscription.util.UserResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportSubmissionService { + + private static final String KAFKA_SOURCE = "report-module"; + private static final String EVENT_VERSION = "1.0"; + + private final ReportService reportService; + private final ReportEventProducer reportEventProducer; + + public ResponseEntity submitReport(String reporterEmail, CreateReportRequest request) { + User reporter = reportService.getUserByEmailOrThrow(reporterEmail); + ResolvedReportTarget target = reportService.validateSubmission(reporter, request); + + ReportSubmittedEvent event = new ReportSubmittedEvent( + KAFKA_SOURCE, + EVENT_VERSION, + reporter.getId().toHexString(), + target.getId(), + target.getType().name(), + request.getReason().name(), + request.getDetails().trim() + ); + + reportEventProducer.publishReportSubmitted(event); + + return UserResponse.responseHandler( + "Report submitted for AI moderation", + HttpStatus.ACCEPTED, + Map.of( + "eventId", event.getEventId(), + "targetId", target.getId(), + "targetType", target.getType().name(), + "status", "QUEUED" + ) + ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 663ec77..8bdd83e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,6 +15,9 @@ spring.data.mongodb.uri=${MONGO_URI} app.cors.url=https://inscriptions.cdacb.in app.backend.url= https://inscriptions.cdacb.in/api +app.frontend.oauth.callback-url=https://inscriptions.cdacb.in/oauth/callback +app.frontend.admin.approval-result-url=https://inscriptions.cdacb.in/admin/approval-result +admin.approval.token-validity-ms=${ADMIN_APPROVAL_TOKEN_VALIDITY_MS:86400000} geolocation.api.url=https://nominatim.openstreetmap.org/reverse @@ -76,3 +79,4 @@ content.moderation.safe-threshold=${CONTENT_MODERATION_SAFE_THRESHOLD} content.moderation.connect-timeout-ms=${CONTENT_MODERATION_CONNECT_TIMEOUT_MS} content.moderation.read-timeout-ms=${CONTENT_MODERATION_READ_TIMEOUT_MS} content.moderation.insecure-ssl=${CONTENT_MODERATION_INSECURE_SSL} +ai.moderation.webhook-url=${AI_MODERATION_WEBHOOK_URL:https://inscriptions.cdacb.in/n8n/webhook/content-moderation} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0194a69..2cf222d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,6 +38,21 @@ spring: # distribution: # percentiles-histogram: # http.server.requests: true + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: stone-inscription-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + properties: + spring.json.trusted.packages: "com.cadac.stone_inscription.kafka.events,com.cadac.stone_inscription.kafka.events.*" + + + security: oauth2: client: @@ -121,4 +136,4 @@ management: metrics: export: prometheus: - enabled: true \ No newline at end of file + enabled: true diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java index 36bd9c0..576ccac 100644 --- a/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java +++ b/src/test/java/com/cadac/stone_inscription/report/ReportModerationTests.java @@ -6,6 +6,7 @@ import org.bson.types.ObjectId; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpHeaders; import com.cadac.stone_inscription.entity.User; import com.cadac.stone_inscription.exception.StoneInscriptionException; @@ -35,7 +36,7 @@ void moderationReportRejectsInvalidStateTransition() { @Test void aiHandlerAutoResolvesHighConfidenceReports() { - AiModerationHandler aiModerationHandler = new AiModerationHandler(); + AiModerationHandler aiModerationHandler = new StubAiModerationHandler("{\"confidence\":0.95}"); TrackingReportActionService reportActionService = new TrackingReportActionService(); ModerationReport report = baseReport(); @@ -67,7 +68,7 @@ void aiHandlerAutoResolvesHighConfidenceReports() { @Test void aiHandlerEscalatesLowConfidenceReports() { - AiModerationHandler aiModerationHandler = new AiModerationHandler(); + AiModerationHandler aiModerationHandler = new StubAiModerationHandler("{\"confidence\":0.20}"); TrackingReportActionService reportActionService = new TrackingReportActionService(); ModerationReport report = baseReport(); @@ -98,6 +99,30 @@ void aiHandlerEscalatesLowConfidenceReports() { assertEquals(0, reportActionService.invocations); } + @Test + void aiHandlerKeepsReportInAiScreeningWhenWebhookIsUnavailable() { + AiModerationHandler aiModerationHandler = new StubAiModerationHandler(null); + TrackingReportActionService reportActionService = new TrackingReportActionService(); + + ModerationReport report = baseReport(); + + ModerationExecutionContext context = ModerationExecutionContext.builder() + .report(report) + .target(baseTarget(new ObjectId().toHexString())) + .actor(User.builder().id(new ObjectId()).build()) + .actorLabel("tester") + .requestedAction(null) + .note(null) + .reportActionService(reportActionService) + .build(); + + aiModerationHandler.handle(context); + + assertEquals(ReportStatus.AI_SCREENING, report.getStatus()); + assertEquals(ModerationAction.NONE, report.getActionTaken()); + assertEquals(0, reportActionService.invocations); + } + @Test void humanHandlerRejectsMissingActionForEscalatedReport() { HumanModerationHandler humanModerationHandler = new HumanModerationHandler(); @@ -174,6 +199,21 @@ void humanHandlerResolvesEscalatedReportWithValidAction() { assertEquals(ModerationAction.BAN_AUTHOR, reportActionService.lastAction); } + @Test + void moderationReportClearsActiveKeyWhenResolved() { + ModerationReport report = baseReport(); + report.setActiveReportKey(ModerationReport.buildActiveReportKey( + report.getReporterId(), + report.getTargetId(), + report.getTargetType())); + + report.transitionTo(ReportStatus.AI_SCREENING, "ai", ModerationAction.NONE, null); + report.transitionTo(ReportStatus.RESOLVED, "ai", ModerationAction.DISMISS, null); + + assertEquals(ReportStatus.RESOLVED, report.getStatus()); + assertEquals(null, report.getActiveReportKey()); + } + private ModerationReport baseReport() { return ModerationReport.builder() .id(new ObjectId()) @@ -217,4 +257,22 @@ public void applyAction( lastAction = action; } } + + private static class StubAiModerationHandler extends AiModerationHandler { + private final String rawResponse; + + StubAiModerationHandler(String rawResponse) { + this.rawResponse = rawResponse; + } + + @Override + protected String invokeWebhook(java.util.Map requestBody, HttpHeaders headers) { + return rawResponse; + } + + @Override + protected boolean isWebhookConfigured() { + return true; + } + } } diff --git a/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java b/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java index 440b578..59c7e7c 100644 --- a/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java +++ b/src/test/java/com/cadac/stone_inscription/report/ReportServiceTests.java @@ -11,12 +11,16 @@ import org.bson.types.ObjectId; import org.junit.jupiter.api.Test; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.HttpHeaders; 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.kafka.events.report.ReportSubmittedEvent; import com.cadac.stone_inscription.report.dto.CreateReportRequest; import com.cadac.stone_inscription.report.dto.ModerateReportRequest; import com.cadac.stone_inscription.report.entity.ModerationReport; @@ -60,7 +64,7 @@ void createReportAutoModeratesHighConfidenceReports() { new ReportFactory(), new FixedTargetResolver(target), reportActionService, - new AiModerationHandler(), + new StubAiModerationHandler("{\"confidence\":0.95}"), new HumanModerationHandler(), List.>of(), new BlacklistGuardService()); @@ -100,7 +104,7 @@ void createReportAutoEscalatesLowConfidenceReports() { new ReportFactory(), new FixedTargetResolver(target), reportActionService, - new AiModerationHandler(), + new StubAiModerationHandler("{\"confidence\":0.20}"), new HumanModerationHandler(), List.>of(), new BlacklistGuardService()); @@ -152,7 +156,7 @@ void moderateReportResolvesEscalatedReportWithModeratorAction() { new ReportFactory(), new FixedTargetResolver(target), reportActionService, - new AiModerationHandler(), + new StubAiModerationHandler("{\"confidence\":0.95}"), new HumanModerationHandler(), List.>of(), new BlacklistGuardService()); @@ -189,7 +193,7 @@ void createReportRejectsBlacklistedReporterBeforeModeration() { new ReportFactory(), new FixedTargetResolver(baseTarget()), reportActionService, - new AiModerationHandler(), + new StubAiModerationHandler("{\"confidence\":0.95}"), new HumanModerationHandler(), List.>of(), new BlacklistGuardService()); @@ -203,6 +207,103 @@ void createReportRejectsBlacklistedReporterBeforeModeration() { assertEquals(0, reportActionService.applyActionInvocations); } + @Test + void createOrGetReportFromEventReturnsExistingActiveReportOnDuplicateKey() { + User reporter = User.builder().id(new ObjectId()).email("reporter@example.com").name("Reporter").build(); + ResolvedReportTarget target = ResolvedReportTarget.builder() + .id(new ObjectId().toHexString()) + .authorId(new ObjectId().toHexString()) + .type(ReportTargetType.POST) + .content("content") + .entity(new Object()) + .build(); + ReportSubmittedEvent event = new ReportSubmittedEvent( + "test", + "1.0", + reporter.getId().toHexString(), + target.getId(), + ReportTargetType.POST.name(), + ReportReason.SPAM.name(), + "obvious scam"); + ModerationReport existingReport = ModerationReport.builder() + .id(new ObjectId()) + .reporterId(reporter.getId().toHexString()) + .targetId(target.getId()) + .targetType(ReportTargetType.POST) + .targetAuthorId(target.getAuthorId()) + .reason(ReportReason.SPAM) + .details("obvious scam") + .status(ReportStatus.PENDING) + .activeReportKey(ModerationReport.buildActiveReportKey( + reporter.getId().toHexString(), + target.getId(), + ReportTargetType.POST)) + .build(); + + TrackingReportActionService reportActionService = new TrackingReportActionService(); + ReportService reportService = new ReportService( + duplicateKeyRepository(existingReport), + userRepository(reporter), + userAuthRepository(null), + new ReportFactory(), + new FixedTargetResolver(target), + reportActionService, + new StubAiModerationHandler("{\"confidence\":0.95}"), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + ModerationReport result = reportService.createOrGetReportFromEvent(event); + + assertEquals(existingReport.getId(), result.getId()); + assertEquals(0, reportActionService.markUnderReviewInvocations); + } + + @Test + void moderateReportReturnsConflictWhenConcurrentUpdateDetected() { + 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) + .version(1L) + .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); + + ReportService reportService = new ReportService( + optimisticLockingRepository(report), + userRepository(moderator), + userAuthRepository(moderatorAuth), + new ReportFactory(), + new FixedTargetResolver(target), + new TrackingReportActionService(), + new StubAiModerationHandler("{\"confidence\":0.95}"), + new HumanModerationHandler(), + List.>of(), + new BlacklistGuardService()); + + StoneInscriptionException exception = assertThrows( + StoneInscriptionException.class, + () -> reportService.moderateReport(moderator.getEmail(), report.getId().toHexString(), request)); + + assertEquals(HttpStatus.CONFLICT, exception.getHttpStatus()); + } + private CreateReportRequest createRequest(ReportReason reason, String details) { CreateReportRequest request = new CreateReportRequest(); request.setTargetType(ReportTargetType.POST); @@ -243,6 +344,48 @@ private ModerationReportRepository moderationReportRepository(ModerationReport f }); } + private ModerationReportRepository duplicateKeyRepository(ModerationReport existingReport) { + return (ModerationReportRepository) Proxy.newProxyInstance( + ModerationReportRepository.class.getClassLoader(), + new Class[] { ModerationReportRepository.class }, + (proxy, method, args) -> { + if ("save".equals(method.getName())) { + throw new DuplicateKeyException("duplicate active report"); + } + if ("findFirstByActiveReportKey".equals(method.getName())) { + return Optional.of(existingReport); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + return defaultValue(method.getReturnType()); + }); + } + + private ModerationReportRepository optimisticLockingRepository(ModerationReport foundReport) { + return (ModerationReportRepository) Proxy.newProxyInstance( + ModerationReportRepository.class.getClassLoader(), + new Class[] { ModerationReportRepository.class }, + (proxy, method, args) -> { + if ("findById".equals(method.getName())) { + return Optional.of(foundReport); + } + if ("save".equals(method.getName())) { + throw new OptimisticLockingFailureException("concurrent update"); + } + 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(), @@ -251,6 +394,9 @@ private UserRepository userRepository(User user) { if ("findByEmail".equals(method.getName())) { return user; } + if ("findById".equals(method.getName()) && user != null && user.getId() != null) { + return Optional.of(user); + } if ("equals".equals(method.getName())) { return proxy == args[0]; } @@ -335,4 +481,22 @@ public void applyAction( lastNote = note; } } + + private static class StubAiModerationHandler extends AiModerationHandler { + private final String rawResponse; + + StubAiModerationHandler(String rawResponse) { + this.rawResponse = rawResponse; + } + + @Override + protected String invokeWebhook(Map requestBody, HttpHeaders headers) { + return rawResponse; + } + + @Override + protected boolean isWebhookConfigured() { + return true; + } + } } From 5b8208e539e86277efcac500a6e6dba0d1ab0c85 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Tue, 19 May 2026 14:30:50 +0530 Subject: [PATCH 04/28] config: configured open API for autogenerate type defination --- pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pom.xml b/pom.xml index 8474480..7b7ed2a 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,13 @@ spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + org.springframework.boot From 930cd75229c1c46349ee5f403f4eb37d75749f7a Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Tue, 19 May 2026 16:49:04 +0530 Subject: [PATCH 05/28] this admin auth system --- .env | 6 ++-- .../admin/service/AdminAccessServiceImpl.java | 20 +++++++++++ .../auth/CustomOAuth2SuccessHandler.java | 1 + .../auth/JwtRequestFilter.java | 23 ++++++------ .../StoneInscriptionUserDetailservice.java | 35 +++++++++++-------- .../StoneinscriptionConfiguration.java | 4 +-- 6 files changed, 58 insertions(+), 31 deletions(-) diff --git a/.env b/.env index 5432865..8b85660 100644 --- a/.env +++ b/.env @@ -27,13 +27,13 @@ CONTENT_MODERATION_INSECURE_SSL=true CONTENT_MODERATION_SAFE_THRESHOLD=0.7 CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 -ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com +ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com # Spring Mail SPRING_MAIL_HOST=smtp.gmail.com SPRING_MAIL_PORT=587 -SPRING_MAIL_USERNAME=your-email@example.com -SPRING_MAIL_PASSWORD=your-app-password +SPRING_MAIL_USERNAME=bavithbabu25@gmail.com +SPRING_MAIL_PASSWORD=whul oduf kaew xboe SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true # CONTENT_MODERATION_INSECURE_SSL=false diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java index acc077a..1d56e64 100644 --- a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java @@ -4,6 +4,8 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; import java.util.Base64; import java.util.Date; @@ -16,6 +18,7 @@ import com.cadac.stone_inscription.admin.repository.AdminRequestRepository; import com.cadac.stone_inscription.entity.UserAuth; import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.repository.UserAuthRepository; import lombok.RequiredArgsConstructor; @@ -25,6 +28,7 @@ public class AdminAccessServiceImpl implements AdminAccessService { private final AdminRequestRepository adminRequestRepository; private final AdminEmailService adminEmailService; + private final UserAuthRepository userAuthRepository; @Value("${app.backend.url}") private String backendUrl; @@ -78,6 +82,7 @@ public void approveRequest(String rawToken) { request.setApprovalTokenExpiresAt(null); adminRequestRepository.save(request); + grantAdminRole(request.getEmail()); adminEmailService.sendApprovalConfirmed(request.getEmail(), request.getName()); } @@ -148,4 +153,19 @@ private String hashToken(String rawToken) { throw new IllegalStateException("SHA-256 algorithm not available", ex); } } + + private void grantAdminRole(String email) { + UserAuth userAuth = userAuthRepository.findByEmail(email); + if (userAuth == null) { + throw new StoneInscriptionException("Approved admin user not found", HttpStatus.NOT_FOUND); + } + + List roles = userAuth.getRoles() == null ? new ArrayList<>() : new ArrayList<>(userAuth.getRoles()); + boolean hasAdminRole = roles.stream().anyMatch(role -> "admin".equalsIgnoreCase(role)); + if (!hasAdminRole) { + roles.add("admin"); + userAuth.setRoles(roles); + userAuthRepository.save(userAuth); + } + } } diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 2be91f0..30e7c6a 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -94,6 +94,7 @@ private UserAuth findOrCreateUser(String email, String name, String picture, Str } UserAuth newUser = UserAuth.builder() + .username(email) .email(email) .provider(provider) .passwordHash("oauth") diff --git a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java index 7864819..6cec1ab 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java +++ b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java @@ -67,9 +67,9 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } - public String extractJwtFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + public String extractJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { // JwtBlackList jwtBlackList = // jwtBlackListRepo.findByJwtToken(bearerToken.substring(7, @@ -84,12 +84,11 @@ public String extractJwtFromRequest(HttpServletRequest request) { // throw new CMPFOException("User Already Loged Out", HttpStatus.UNAUTHORIZED); // } - - } else { - throw new StoneInscriptionException("Invalid Token Request Bearer not found ", HttpStatus.BAD_REQUEST); - // return null; - } - - } - -} + + } else { + return null; + } + + } + +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/StoneInscriptionUserDetailservice.java b/src/main/java/com/cadac/stone_inscription/auth/StoneInscriptionUserDetailservice.java index 407a6a7..e11756a 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/StoneInscriptionUserDetailservice.java +++ b/src/main/java/com/cadac/stone_inscription/auth/StoneInscriptionUserDetailservice.java @@ -1,11 +1,12 @@ -package com.cadac.stone_inscription.auth; - -import java.util.ArrayList; -import java.util.Optional; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; +package com.cadac.stone_inscription.auth; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @@ -39,7 +40,7 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx - if(user != null ){ + if(user != null ){ // ArrayList tAssignRoles = tAssignRolesRepo.findByEmail(username); @@ -56,11 +57,17 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx // }); - return new User(user.getEmail(),user.getPasswordHash(), new ArrayList<>()); - - - - } + List authorities = user.getRoles() == null + ? new ArrayList<>() + : user.getRoles().stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toCollection(ArrayList::new)); + + return new User(user.getEmail(),user.getPasswordHash(), authorities); + + + + } else { throw new UsernameNotFoundException(username); } diff --git a/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java index 2048a62..2ed5805 100644 --- a/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java +++ b/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java @@ -100,8 +100,8 @@ public SecurityFilterChain filterChain(HttpSecurity http, .authorizeHttpRequests(authz -> authz .requestMatchers("/api/v1/noauth/**", "/post/public/**").permitAll() - .requestMatchers("/api/v1/**", "/post/**").authenticated() - // .requestMatchers("/api/v1/**", "/post/**").permitAll() + // .requestMatchers("/api/v1/**", "/post/**").authenticated() + .requestMatchers("/api/v1/**", "/post/**").permitAll() .requestMatchers("/oauth2/**", "/oauth2/login/**").permitAll() .anyRequest().permitAll()) From c4b656f59fa7012cd77fff57f88c57feea1ff9f9 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Tue, 19 May 2026 16:56:55 +0530 Subject: [PATCH 06/28] local swager setup done --- .../cadac/stone_inscription/OAuthDemo.java | 102 +++++++------ .../api/dto/AccessTokenResponse.java | 18 +++ .../api/dto/ApiErrorResponse.java | 44 ++++++ .../api/dto/ApiSuccessResponse.java | 42 ++++++ .../api/dto/DashboardCountsResponse.java | 62 ++++++++ .../api/dto/ReportQueuedResponse.java | 51 +++++++ .../auth/controller/OAuthController.java | 64 +++++++- .../configuration/OpenApiConfiguration.java | 134 +++++++++++++++++ .../dto/ContentModerationRequestDto.java | 5 + .../dto/ContentModerationResponseDto.java | 12 ++ .../post/controller/PostController.java | 138 +++++++++++++++++- .../post/dto/InscriptionPostDto.java | 106 ++++++++------ .../dto/PublicPostUserDescriptionDto.java | 69 +++++---- .../report/controller/ReportController.java | 50 +++++++ .../report/dto/CreateReportRequest.java | 6 + .../report/dto/ModerateReportRequest.java | 4 + .../report/entity/ModerationReport.java | 15 ++ .../report/entity/ReportAuditEntry.java | 5 + .../user/controller/UserController.java | 51 +++++++ .../user/dto/UpdateProfileRequest.java | 4 + .../user/dto/UserProfileResponse.java | 13 ++ src/main/resources/application.properties | 10 ++ 22 files changed, 868 insertions(+), 137 deletions(-) create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java diff --git a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java index af6cdd8..f59cc7e 100644 --- a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java +++ b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java @@ -4,50 +4,62 @@ import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@RestController -@RequestMapping("/api/v1") -public class OAuthDemo { - - @GetMapping("/") - public String getMethodName(@RequestParam String param) { - return "hello "; - } - - @Secured( "user" ) - @GetMapping("/fail") - public String getfail() { - return "hello fail "; - } - - @GetMapping("/noauth/check") - public String noAuth() { - return "This endpoint does not require authentication."; - } - - // Direct Facebook login endpoint - @GetMapping("/auth/facebook") - public String facebookLogin() { - return "redirect:/oauth2/authorization/facebook"; - } - - @Secured({ "admin" }) - @GetMapping("/auth") - public String auth() { - return "This endpoint requires authentication."; - } - - @GetMapping("/auth/token") - public String authToken() { - return "Authentication successful. You can now access secured endpoints."; - } - - @Secured({ "user", "admin" }) - @GetMapping("/authrole") - public String authRole() { - return "This endpoint requires authentication with role."; - } +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "Authentication", description = "OAuth smoke-test and role-check endpoints.") +public class OAuthDemo { + + @GetMapping("/") + @Operation(summary = "API smoke test", description = "Returns a minimal success message for connectivity checks.") + public String getMethodName(@Parameter(description = "Echo parameter placeholder.", example = "ping") @RequestParam String param) { + return "hello "; + } + + @Secured( "user" ) + @GetMapping("/fail") + @Operation(summary = "User role check", description = "Requires a user role and returns a simple role-check message.") + public String getfail() { + return "hello fail "; + } + + @GetMapping("/noauth/check") + @Operation(summary = "Public auth health check", description = "Confirms that public no-auth routes are reachable.") + public String noAuth() { + return "This endpoint does not require authentication."; + } + + // Direct Facebook login endpoint + @GetMapping("/auth/facebook") + @Operation(summary = "Facebook login redirect marker", description = "Legacy endpoint that returns the Facebook OAuth redirect target as text.") + public String facebookLogin() { + return "redirect:/oauth2/authorization/facebook"; + } + + @Secured({ "admin" }) + @GetMapping("/auth") + @Operation(summary = "Admin auth check", description = "Requires an admin role and returns a simple authenticated message.") + public String auth() { + return "This endpoint requires authentication."; + } + + @GetMapping("/auth/token") + @Operation(summary = "Token success marker", description = "Returns a text message used after successful authentication flows.") + public String authToken() { + return "Authentication successful. You can now access secured endpoints."; + } + + @Secured({ "user", "admin" }) + @GetMapping("/authrole") + @Operation(summary = "User or admin role check", description = "Requires either user or admin role.") + public String authRole() { + return "This endpoint requires authentication with role."; + } } diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java new file mode 100644 index 0000000..3b06ed6 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java @@ -0,0 +1,18 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AccessTokenResponse", description = "Access token payload returned after refresh-token rotation.") +public class AccessTokenResponse { + + @Schema(description = "New short-lived JWT access token.", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuser@example.com\"") + private String accessToken; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java new file mode 100644 index 0000000..a255791 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java @@ -0,0 +1,44 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiErrorResponse", description = "Standard error body returned by API exception handlers.") +public class ApiErrorResponse { + + @Schema(description = "Human-readable error message.", example = "Invalid or missing authorization token") + @JsonProperty("error_message") + private String errorMessage; + + @Schema(description = "HTTP status reason or numeric status used by the handler.", example = "UNAUTHORIZED") + @JsonProperty("http_status") + private Object httpStatus; + + @Schema(description = "HTTP status code.", example = "401") + @JsonProperty("http_status_code") + private Integer httpStatusCode; + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public Integer getHttpStatusCode() { + return httpStatusCode; + } + + public void setHttpStatusCode(Integer httpStatusCode) { + this.httpStatusCode = httpStatusCode; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java new file mode 100644 index 0000000..783f6e4 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java @@ -0,0 +1,42 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiSuccessResponse", description = "Standard success envelope returned by most JSON endpoints.") +public class ApiSuccessResponse { + + @Schema(description = "Operation result message.", example = "Profile fetched successfully") + private String message; + + @Schema(description = "HTTP status reason used by the response envelope.", example = "OK") + @JsonProperty("http-status") + private Object httpStatus; + + @Schema(description = "Endpoint-specific payload.") + private T data; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java new file mode 100644 index 0000000..705f3cf --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java @@ -0,0 +1,62 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DashboardCountsResponse", description = "Public aggregate counters for the application dashboard.") +public class DashboardCountsResponse { + + @Schema(description = "Registered users.", example = "128") + private Integer totalUsers; + + @Schema(description = "Published posts.", example = "42") + private Integer totalPosts; + + @Schema(description = "Uploaded image objects.", example = "280") + private Integer totalImages; + + @Schema(description = "Posts with extracted geolocation metadata.", example = "31") + private Integer totalGeoTaggedPosts; + + @Schema(description = "Posts with an English translation available.", example = "17") + private Integer totalTranslations; + + public Integer getTotalUsers() { + return totalUsers; + } + + public void setTotalUsers(Integer totalUsers) { + this.totalUsers = totalUsers; + } + + public Integer getTotalPosts() { + return totalPosts; + } + + public void setTotalPosts(Integer totalPosts) { + this.totalPosts = totalPosts; + } + + public Integer getTotalImages() { + return totalImages; + } + + public void setTotalImages(Integer totalImages) { + this.totalImages = totalImages; + } + + public Integer getTotalGeoTaggedPosts() { + return totalGeoTaggedPosts; + } + + public void setTotalGeoTaggedPosts(Integer totalGeoTaggedPosts) { + this.totalGeoTaggedPosts = totalGeoTaggedPosts; + } + + public Integer getTotalTranslations() { + return totalTranslations; + } + + public void setTotalTranslations(Integer totalTranslations) { + this.totalTranslations = totalTranslations; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java new file mode 100644 index 0000000..c8e3398 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java @@ -0,0 +1,51 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "ReportQueuedResponse", description = "Payload returned after a report is accepted for asynchronous moderation.") +public class ReportQueuedResponse { + + @Schema(description = "Published report event identifier.", example = "7f8b7d33-16f2-4e84-9f54-8085a9e84791") + private String eventId; + + @Schema(description = "Identifier of the reported content or user.", example = "665f1df013ad4e18f6a11244") + private String targetId; + + @Schema(description = "Reported resource type.", example = "POST") + private String targetType; + + @Schema(description = "Queue processing state.", example = "QUEUED") + private String status; + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public String getTargetType() { + return targetType; + } + + public void setTargetType(String targetType) { + this.targetType = targetType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index d83ad5b..2d2fc05 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.admin.service.AdminAccessService; +import com.cadac.stone_inscription.api.dto.AccessTokenResponse; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.auth.OAuthFlowCookieService; import com.cadac.stone_inscription.auth.OAuthFlowType; import com.cadac.stone_inscription.auth.JwtUtil; @@ -26,12 +29,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; - -@RestController -@RequestMapping("/oauth2") - + +@RestController +@RequestMapping("/oauth2") +@Tag(name = "Authentication", description = "OAuth login redirects, refresh-token rotation, session activity, and logout.") + public class OAuthController { @Autowired @@ -48,21 +59,41 @@ public class OAuthController { @PostMapping("/logout") + @Operation( + summary = "Logout", + description = "Revokes the refresh token cookie when present and expires the browser cookie.", + responses = @ApiResponse(responseCode = "200", description = "Logged out", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Logged out successfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { return stoneAuthService.logoutAuth(request, response); } - @PostMapping("/authenticated/refresh-token") - public ResponseEntity refreshToken(HttpServletResponse response, HttpServletRequest request) + @PostMapping("/authenticated/refresh-token") + @Operation( + summary = "Refresh access token", + description = "Rotates the HTTP-only refresh token cookie and returns a new JWT access token.", + responses = { + @ApiResponse(responseCode = "200", description = "Token refreshed", + content = @Content(schema = @Schema(implementation = AccessTokenResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Sucessfully updated\",\"http-status\":\"OK\",\"data\":{\"accessToken\":\"eyJhbGciOiJIUzI1NiJ9...\"}}"))), + @ApiResponse(responseCode = "401", description = "Refresh token is missing, revoked, or expired", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + public ResponseEntity refreshToken(HttpServletResponse response, HttpServletRequest request) throws UsernameNotFoundException, JOSEException { return stoneAuthService.refreshToken(request, response); } - @PostMapping("/authenticated/active") - public ResponseEntity updateLastActive(HttpServletRequest request) { + @PostMapping("/authenticated/active") + @Operation( + summary = "Mark session active", + description = "Refreshes last-use time for the refresh-token session. Returns no content on success.", + responses = @ApiResponse(responseCode = "204", description = "Session marked active")) + public ResponseEntity updateLastActive(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { throw new StoneInscriptionException("Unauthorized", HttpStatus.UNAUTHORIZED); @@ -81,7 +112,12 @@ public ResponseEntity updateLastActive(HttpServletRequest request) { } @GetMapping("/login/{provider}") + @Operation( + summary = "Start user OAuth login", + description = "Stores the user-login flow marker and redirects to the configured OAuth provider.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to OAuth provider")) public void loginWithProvider( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.USER_LOGIN); @@ -89,12 +125,15 @@ public void loginWithProvider( } @GetMapping("/login") + @Operation(summary = "Start default user OAuth login", description = "Redirects to the configured default OAuth provider.") public void login(HttpServletResponse response) throws IOException { loginWithProvider(defaultProvider, response); } @GetMapping("/admin/register/{provider}") + @Operation(summary = "Start admin registration", description = "Starts OAuth flow for an admin registration request.") public void adminRegister( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_REGISTER); @@ -102,12 +141,15 @@ public void adminRegister( } @GetMapping("/admin/register") + @Operation(summary = "Start default admin registration", description = "Starts admin registration using the configured default OAuth provider.") public void adminRegisterDefault(HttpServletResponse response) throws IOException { adminRegister(defaultProvider, response); } @GetMapping("/admin/login/{provider}") + @Operation(summary = "Start admin OAuth login", description = "Starts OAuth login for an approved admin account.") public void adminLogin( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_LOGIN); @@ -115,12 +157,18 @@ public void adminLogin( } @GetMapping("/admin/login") + @Operation(summary = "Start default admin OAuth login", description = "Starts admin login using the configured default OAuth provider.") public void adminLoginDefault(HttpServletResponse response) throws IOException { adminLogin(defaultProvider, response); } @GetMapping("/admin/approve") + @Operation( + summary = "Approve admin request", + description = "Consumes an emailed approval token and redirects the browser to the configured approval-result page.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to approval result page")) public void approveAdminRequest( + @Parameter(description = "Admin approval token.", required = true) @RequestParam("token") String token, HttpServletResponse response) throws IOException { try { diff --git a/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java new file mode 100644 index 0000000..de35f49 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java @@ -0,0 +1,134 @@ +package com.cadac.stone_inscription.configuration; + +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.method.HandlerMethod; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +@Configuration +@SecurityScheme( + name = OpenApiConfiguration.BEARER_AUTH, + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER) +public class OpenApiConfiguration { + + public static final String BEARER_AUTH = "bearerAuth"; + + private static final String ERROR_SCHEMA_REF = "#/components/schemas/ApiErrorResponse"; + + @Bean + public OpenAPI stoneInscriptionOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Stone Inscription API") + .version("v1") + .description(""" + REST API for OAuth authentication, user profiles, inscription posts, images, public dashboard metrics, and moderation reports. + + Most JSON endpoints return a standard envelope with `message`, `http-status`, and `data`. Protected endpoints use a JWT bearer token in the `Authorization` header. + """) + .contact(new Contact().name("C-DAC Stone Inscription")) + .license(new License().name("Internal"))) + .components(new Components()); + } + + @Bean + public GroupedOpenApi authenticationApi() { + return GroupedOpenApi.builder() + .group("01-authentication") + .displayName("Authentication") + .pathsToMatch("/oauth2/**", "/api/v1/**") + .pathsToExclude("/api/v1/") + .build(); + } + + @Bean + public GroupedOpenApi userApi() { + return GroupedOpenApi.builder() + .group("02-users") + .displayName("Users") + .pathsToMatch("/user/**") + .build(); + } + + @Bean + public GroupedOpenApi postApi() { + return GroupedOpenApi.builder() + .group("03-posts") + .displayName("Posts") + .pathsToMatch("/post/**") + .pathsToExclude("/post/test/**") + .build(); + } + + @Bean + public GroupedOpenApi reportApi() { + return GroupedOpenApi.builder() + .group("04-reports") + .displayName("Reports") + .pathsToMatch("/report", "/reports", "/moderate/**") + .pathsToExclude("/test/**") + .build(); + } + + @Bean + public GlobalOperationCustomizer commonApiResponsesCustomizer() { + return (operation, handlerMethod) -> { + if (operation.getResponses() == null) { + operation.setResponses(new ApiResponses()); + } + + if (isSecured(handlerMethod)) { + operation.addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); + addErrorResponse(operation, "401", "JWT is missing, expired, or invalid."); + addErrorResponse(operation, "403", "Authenticated user does not have the required role."); + } + + addErrorResponse(operation, "400", "Request is syntactically valid but violates business rules."); + addErrorResponse(operation, "422", "Bean validation failed for request body, form, or query parameters."); + addErrorResponse(operation, "500", "Unexpected server error."); + + return operation; + }; + } + + private boolean isSecured(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(Secured.class) + || handlerMethod.getBeanType().isAnnotationPresent(Secured.class); + } + + private void addErrorResponse(Operation operation, String code, String description) { + if (operation.getResponses().containsKey(code)) { + return; + } + + operation.getResponses().addApiResponse(code, new ApiResponse() + .description(description) + .content(jsonContent(ERROR_SCHEMA_REF))); + } + + private Content jsonContent(String schemaRef) { + return new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(new Schema<>().$ref(schemaRef))); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java index 4ec8632..4ae2759 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,14 +12,18 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ContentModerationRequest", description = "Text fields sent to the content moderation workflow.") public class ContentModerationRequestDto { @JsonProperty("title") + @Schema(description = "Content title.", example = "Ashokan pillar fragment") private String title; @JsonProperty("topic") + @Schema(description = "Content topic.", example = "Temple inscription") private String topic; @JsonProperty("description") + @Schema(description = "Content body to moderate.", example = "Fragmentary stone inscription found near the temple entrance.") private String description; } 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 ef985d0..c1bf026 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 @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -14,40 +15,51 @@ @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) +@Schema(name = "ContentModerationResponse", description = "Normalized moderation webhook response.") public class ContentModerationResponseDto { @JsonProperty("timestamp") + @Schema(description = "Webhook timestamp.", example = "2026-05-19T10:35:00Z") private String timestamp; @JsonProperty("decision") @JsonAlias({ "verdict", "action" }) + @Schema(description = "Moderation decision.", example = "ALLOW") private String decision; @JsonProperty("label") @JsonAlias({ "category", "classification" }) + @Schema(description = "Moderation category or label.", example = "safe") private String label; @JsonProperty("confidence") @JsonAlias({ "score", "confidenceScore", "confidence_score", "probability" }) + @Schema(description = "Confidence score from 0 to 1 when supplied.", example = "0.91") private Double confidence; @JsonProperty("reason") @JsonAlias({ "message", "explanation" }) + @Schema(description = "Human-readable moderation reason.", example = "No unsafe content detected") private String reason; @JsonProperty("status") @JsonAlias({ "state" }) + @Schema(description = "Webhook processing status.", example = "APPROVED") private String status; @JsonProperty("description") + @Schema(description = "Moderated content description returned by the workflow.") private String description; @JsonProperty("id") + @Schema(description = "Webhook-side moderation id.", example = "5006") private Long id; @JsonProperty("createdAt") + @Schema(description = "Webhook record creation timestamp.", example = "2026-05-19T10:35:00Z") private String createdAt; @JsonProperty("updatedAt") + @Schema(description = "Webhook record update timestamp.", example = "2026-05-19T10:35:01Z") private String updatedAt; } 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 320f251..111057b 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 @@ -21,14 +21,26 @@ import org.springframework.web.multipart.MultipartFile; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.DashboardCountsResponse; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.post.dto.InscriptionPostDto; import com.cadac.stone_inscription.post.service.PostService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping("/post") +@Tag(name = "Posts", description = "Inscription post, image, description, rating, and dashboard APIs.") public class PostController { private static final int MAX_IMAGES_PER_POST = 16; @@ -45,9 +57,17 @@ public class PostController { @PostMapping("/addPostWithFile") @Secured("user") + @Operation( + summary = "Create post with images", + description = "Creates an inscription post from multipart metadata and one or more images. The server validates extension, image count, size, metadata, geolocation, perceptual hash data, and content moderation.", + responses = @ApiResponse(responseCode = "200", description = "Post images uploaded", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Images Uploaded Sucessfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity addPostWithFile( + @Parameter(description = "Post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, HttpServletRequest request, + @Parameter(description = "Image files. Maximum 16 files, 75 MB each.", required = true) @RequestPart("files") MultipartFile... files) throws IOException { files = getNonEmptyFiles(files); @@ -68,6 +88,11 @@ public ResponseEntity addPostWithFile( @PostMapping("/getAllPost") @Secured("user") + @Operation( + summary = "List all visible posts", + description = "Returns all posts with image identifiers expanded to public image URLs.", + responses = @ApiResponse(responseCode = "200", description = "Posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getAllPost() { return postService.getAllPost(); @@ -75,6 +100,15 @@ public ResponseEntity getAllPost() { } @GetMapping("/public/images/{id}") + @Operation( + summary = "Download post image", + description = "Public endpoint that streams a stored inscription image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getImage(@PathVariable String id) { return postService.getImages(id); @@ -83,6 +117,11 @@ public ResponseEntity getImage(@PathVariable String id) { @PostMapping("/getAllUserPost") @Secured("user") + @Operation( + summary = "List my posts", + description = "Returns posts created by the authenticated user with image URLs hydrated.", + responses = @ApiResponse(responseCode = "200", description = "User posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getAllUserPost(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -97,8 +136,14 @@ public ResponseEntity getAllUserPost(HttpServletRequest request) { @PostMapping("/addPoastDiscription") @Secured("user") - public ResponseEntity addPoastDiscription(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("discription") String discription) { + @Operation( + summary = "Add post description", + description = "Adds a user-authored description/comment to a post after content moderation. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addPoastDiscription(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Description text.", example = "This line appears to mention a land grant.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -112,7 +157,13 @@ public ResponseEntity addPoastDiscription(HttpServletRequest request, @Reques @PostMapping("/getPostDiscription") @Secured("user") - public ResponseEntity getPostDiscription(@RequestParam("postId") String postId) { + @Operation( + summary = "List post descriptions", + description = "Returns all user descriptions/comments for a post. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity getPostDiscription( + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { return postService.getPostDiscription( postId); @@ -121,8 +172,15 @@ public ResponseEntity getPostDiscription(@RequestParam("postId") String postI @PostMapping("/updatePostDiscription") @Secured("user") + @Operation( + summary = "Update post description", + description = "Updates an authenticated user's own description/comment after moderation.", + responses = @ApiResponse(responseCode = "200", description = "Description updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity updatePostDiscription(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("discriptionId") String discriptionId, + @Parameter(description = "Updated description text.", example = "Updated historical reading.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -136,8 +194,14 @@ public ResponseEntity updatePostDiscription(HttpServletRequest request, @PostMapping("/addRating") @Secured("user") - public ResponseEntity addRating(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("rating") Double rating) { + @Operation( + summary = "Rate post", + description = "Adds or updates the authenticated user's numeric rating for a post.", + responses = @ApiResponse(responseCode = "200", description = "Rating added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addRating(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Rating value.", example = "4.5") @RequestParam("rating") Double rating) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -151,7 +215,13 @@ public ResponseEntity addRating(HttpServletRequest request, @RequestParam("po @PostMapping("/addVote") @Secured("user") - public ResponseEntity addVote(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Toggle description vote", + description = "Adds an upvote when the authenticated user has not voted, or removes the existing vote.", + responses = @ApiResponse(responseCode = "200", description = "Vote updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addVote(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -165,6 +235,11 @@ public ResponseEntity addVote(HttpServletRequest request, @RequestParam("desc @PostMapping("/userProfile") @Secured("user") + @Operation( + summary = "Get current user entity profile", + description = "Legacy post-module profile endpoint that returns the authenticated user object in the standard envelope.", + responses = @ApiResponse(responseCode = "200", description = "User profile fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity userProfile(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -177,7 +252,13 @@ public ResponseEntity userProfile(HttpServletRequest request) { @PostMapping("/postDelete") @Secured("user") - public ResponseEntity postDelete(HttpServletRequest request, @RequestParam("postId") String postId) { + @Operation( + summary = "Delete my post", + description = "Deletes a post owned by the authenticated user and archives related content according to the content delete service.", + responses = @ApiResponse(responseCode = "200", description = "Post deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity postDelete(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -190,7 +271,13 @@ public ResponseEntity postDelete(HttpServletRequest request, @RequestParam("p @PostMapping("/discriptionDelete") @Secured("user") - public ResponseEntity descriptionDelete(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Delete my description", + description = "Deletes a description/comment owned by the authenticated user. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity descriptionDelete(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -204,10 +291,19 @@ public ResponseEntity descriptionDelete(HttpServletRequest request, @RequestP // Helps logged user to create a post and upload images @PostMapping("/updatePost") @Secured("user") + @Operation( + summary = "Update post metadata and images", + description = "Updates post metadata, removes selected images, adds new images, and enforces that at least one image remains and no more than 16 images are attached.", + responses = @ApiResponse(responseCode = "200", description = "Post updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity updatePost(HttpServletRequest request, + @Parameter(description = "Updated post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to remove from the post.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds", required = false) List deletedImageIds, + @Parameter(description = "New image files to add.", required = false) @RequestPart(value = "files", required = false) MultipartFile... files) { files = getNonEmptyFiles(files); @@ -225,8 +321,15 @@ public ResponseEntity updatePost(HttpServletRequest request, @PostMapping("/addImagesToPost") @Secured("user") + @Operation( + summary = "Add images to post", + description = "Adds one or more images to an owned post while enforcing extension, size, and max image-count rules.", + responses = @ApiResponse(responseCode = "200", description = "Images added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity addImagesToPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image files. Maximum 16 images per post total.", required = true) @RequestPart("files") MultipartFile... files) { files = getNonEmptyFiles(files); @@ -247,8 +350,15 @@ public ResponseEntity addImagesToPost(HttpServletRequest request, @PostMapping("/deleteImagesFromPost") @Secured("user") + @Operation( + summary = "Delete post images", + description = "Deletes selected images from an owned post while ensuring the post still has at least one image.", + responses = @ApiResponse(responseCode = "200", description = "Images deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity deleteImagesFromPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to delete.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds") List deletedImageIds) { String token = request.getHeader("Authorization"); @@ -279,6 +389,7 @@ public ResponseEntity deleteImagesFromPost(HttpServletRequest request, // } @PostMapping("/test/addPostWithFile/{email}") + @Hidden public ResponseEntity addPostWithFileForTest( @PathVariable String email, @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, @@ -375,6 +486,11 @@ private void validateFiles(MultipartFile[] files, int maxFilesAllowed) { @PostMapping("/getCommentByUser") // @Secured("user") + @Operation( + summary = "List my descriptions", + description = "Returns descriptions/comments authored by the authenticated user with post preview image URLs.", + responses = @ApiResponse(responseCode = "200", description = "User descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getCommentByUser(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -388,6 +504,12 @@ public ResponseEntity getCommentByUser(HttpServletRequest request) { @GetMapping("/public/getDashboardCounts") // @Secured("user") + @Operation( + summary = "Get public dashboard counts", + description = "Returns public aggregate counts used by the dashboard.", + responses = @ApiResponse(responseCode = "200", description = "Dashboard counts fetched", + content = @Content(schema = @Schema(implementation = DashboardCountsResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Dashboard Counts\",\"http-status\":\"OK\",\"data\":{\"totalUsers\":128,\"totalPosts\":42,\"totalImages\":280,\"totalGeoTaggedPosts\":31,\"totalTranslations\":17}}")))) public ResponseEntity getDashboardCounts() { return postService.getDashboardCounts(); diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java index 3bd2532..727e0db 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java @@ -1,56 +1,70 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class InscriptionPostDto { - - @JsonProperty("description") - private DescriptionDto description; - - @JsonProperty("topic") - private String topic; - - @JsonProperty("script") - private List script; - - @JsonProperty("type") - private String type; - - @JsonProperty("visiblity") - private Boolean visiblity; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "InscriptionPost", description = "Post metadata submitted with one or more inscription images in multipart requests.") +public class InscriptionPostDto { + + @JsonProperty("description") + @Schema(description = "Localized inscription description and language metadata.") + private DescriptionDto description; + + @JsonProperty("topic") + @Schema(description = "High-level post topic.", example = "Temple inscription") + private String topic; + + @JsonProperty("script") + @ArraySchema(schema = @Schema(description = "Script family used in the inscription.", example = "Brahmi")) + private List script; + + @JsonProperty("type") + @Schema(description = "Content type or category.", example = "STONE_INSCRIPTION") + private String type; + + @JsonProperty("visiblity") + @Schema(description = "Whether the post should be visible publicly. Field name preserves the existing API spelling.", example = "true") + private Boolean visiblity; @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DescriptionDto { - - @JsonProperty("title") - private String title; - - @JsonProperty("subject") - private String subject; - - @JsonProperty("description") - private String description; - - @JsonProperty("scriptLanguage") - private List scriptLanguage; - - @JsonProperty("language") - private List language; - - } + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "InscriptionPostDescription", description = "Human-readable inscription description payload.") + public static class DescriptionDto { + + @JsonProperty("title") + @Schema(description = "Display title for the inscription.", example = "Ashokan pillar fragment") + private String title; + + @JsonProperty("subject") + @Schema(description = "Primary subject of the inscription.", example = "Donation record") + private String subject; + + @JsonProperty("description") + @Schema(description = "Detailed inscription description.", example = "Fragmentary stone inscription found near the temple entrance.") + private String description; + + @JsonProperty("scriptLanguage") + @ArraySchema(schema = @Schema(description = "Script language.", example = "Prakrit")) + private List scriptLanguage; + + @JsonProperty("language") + @ArraySchema(schema = @Schema(description = "Readable language.", example = "Hindi")) + private List language; + + } } diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java index 40068df..a912f24 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java @@ -1,25 +1,30 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PublicPostUserDescriptionDto { - - @JsonProperty("id") - private String id; - - @JsonProperty("postId") - private String postId; - - @JsonProperty("userId") - private String userId; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "PublicPostUserDescription", description = "Description/comment authored by a user with voting and image preview details.") +public class PublicPostUserDescriptionDto { + + @JsonProperty("id") + @Schema(description = "Description identifier.", example = "665f1df013ad4e18f6a11247") + private String id; + + @JsonProperty("postId") + @Schema(description = "Associated post identifier.", example = "665f1df013ad4e18f6a11244") + private String postId; + + @JsonProperty("userId") + @Schema(description = "Author user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; @JsonProperty("username") private String username; @@ -30,11 +35,13 @@ public class PublicPostUserDescriptionDto { @JsonProperty("postImageUrl") private String postImageUrl; - @JsonProperty("description") - private String description; - - @JsonProperty("upvote") - private Integer upvote; + @JsonProperty("description") + @Schema(description = "Description text.", example = "The inscription appears to reference a land grant.") + private String description; + + @JsonProperty("upvote") + @Schema(description = "Current upvote count.", example = "7") + private Integer upvote; @JsonProperty("userVote") private List userVote; @@ -45,12 +52,14 @@ public class PublicPostUserDescriptionDto { @JsonProperty("updatedAt") private Date updatedAt; - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class UserVoteDto { - @JsonProperty("userId") - private String userId; - } -} + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "UserVote", description = "User vote marker for a description.") + public static class UserVoteDto { + @JsonProperty("userId") + @Schema(description = "Voting user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; + } +} 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 index 34d6723..79d67da 100644 --- a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java +++ b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java @@ -11,19 +11,33 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.ReportQueuedResponse; 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.service.ReportService; import com.cadac.stone_inscription.report.service.ReportSubmissionService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor +@Tag(name = "Reports", description = "User reporting and moderator review workflow.") public class ReportController { private final ReportService reportService; @@ -32,6 +46,20 @@ public class ReportController { @PostMapping("/report") @Secured({ "user", "admin" }) + @Operation( + summary = "Submit report", + description = "Accepts a report from the authenticated user, validates target/reporting rules, and queues the report for AI moderation.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = CreateReportRequest.class), + examples = @ExampleObject(value = "{\"targetType\":\"POST\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"reason\":\"MISINFORMATION\",\"details\":\"The description attributes the inscription to the wrong dynasty.\"}"))), + responses = { + @ApiResponse(responseCode = "202", description = "Report queued", + content = @Content(schema = @Schema(implementation = ReportQueuedResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Report submitted for AI moderation\",\"http-status\":\"ACCEPTED\",\"data\":{\"eventId\":\"7f8b7d33-16f2-4e84-9f54-8085a9e84791\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"targetType\":\"POST\",\"status\":\"QUEUED\"}}"))), + @ApiResponse(responseCode = "400", description = "Invalid target, duplicate report, or self-report", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity createReport( HttpServletRequest request, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -40,6 +68,7 @@ public ResponseEntity createReport( } @PostMapping("/test/report/{email}") + @Hidden public ResponseEntity createReportForTest( @PathVariable String email, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -49,14 +78,21 @@ public ResponseEntity createReportForTest( @GetMapping("/reports") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "List moderation reports", + description = "Returns moderation reports ordered by creation time. Moderators may optionally filter by status.", + responses = @ApiResponse(responseCode = "200", description = "Reports fetched", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ModerationReport.class))))) public ResponseEntity getReports( HttpServletRequest request, + @Parameter(description = "Optional report status filter.", example = "ESCALATED") @RequestParam(required = false) ReportStatus status) { return reportService.getReports(extractEmailFromToken(request), status); } @GetMapping("/test/reports/{email}") + @Hidden public ResponseEntity getReportsForTest( @PathVariable String email, @RequestParam(required = false) ReportStatus status) { @@ -66,6 +102,19 @@ public ResponseEntity getReportsForTest( @PostMapping("/moderate/{id}") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "Moderate report", + description = "Runs the moderation chain for a report. Human moderator roles are required when an escalated report needs final resolution.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = false, + content = @Content(schema = @Schema(implementation = ModerateReportRequest.class), + examples = @ExampleObject(value = "{\"action\":\"REMOVE_CONTENT\",\"note\":\"Removed after human review.\"}"))), + responses = { + @ApiResponse(responseCode = "200", description = "Report moderation completed", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class))), + @ApiResponse(responseCode = "404", description = "Report not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity moderateReport( HttpServletRequest request, @PathVariable String id, @@ -75,6 +124,7 @@ public ResponseEntity moderateReport( } @PostMapping("/test/moderate/{id}/{email}") + @Hidden public ResponseEntity moderateReportForTest( @PathVariable String id, @PathVariable String email, 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 index 2390801..85ffb0b 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java @@ -3,23 +3,29 @@ import com.cadac.stone_inscription.report.enums.ReportReason; import com.cadac.stone_inscription.report.enums.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "CreateReportRequest", description = "Request used by authenticated users to report a post, comment, or user.") public class CreateReportRequest { + @Schema(description = "Type of resource being reported.", example = "POST", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportTargetType targetType; + @Schema(description = "MongoDB ObjectId or canonical identifier of the reported resource.", example = "665f1df013ad4e18f6a11244", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank private String targetId; + @Schema(description = "Reason selected by the reporter.", example = "MISINFORMATION", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportReason reason; + @Schema(description = "Reporter-provided context for moderators. Maximum 1000 characters.", example = "The inscription description contains misleading historical attribution.", maxLength = 1000, requiredMode = Schema.RequiredMode.REQUIRED) @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 index 35e26c5..8ce742d 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java @@ -2,14 +2,18 @@ import com.cadac.stone_inscription.report.enums.ModerationAction; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "ModerateReportRequest", description = "Optional instruction supplied by a moderator when processing a report.") public class ModerateReportRequest { + @Schema(description = "Moderation action to apply. If omitted, the moderation chain decides the next transition.", example = "REMOVE_CONTENT") private ModerationAction action; + @Schema(description = "Moderator note saved in the report audit trail. Maximum 1000 characters.", example = "Removed duplicate inscription image after review.", maxLength = 1000) @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 index b4fa165..11d31fb 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java @@ -28,6 +28,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -42,42 +44,51 @@ @CompoundIndex(name = "report_target_idx", def = "{'targetId': 1, 'targetType': 1}"), @CompoundIndex(name = "reporter_target_idx", def = "{'reporterId': 1, 'targetId': 1, 'targetType': 1}") }) +@Schema(name = "ModerationReport", description = "Moderation report state, target metadata, AI score, and audit history.") public class ModerationReport { @Id @JsonProperty("_id") @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "Report identifier.", example = "665f1df013ad4e18f6a11250") private ObjectId id; @Field("reporterId") @JsonProperty("reporterId") @Indexed + @Schema(description = "Reporter user id.", example = "665f1df013ad4e18f6a11240") private String reporterId; @Field("targetId") @JsonProperty("targetId") @Indexed + @Schema(description = "Reported target id.", example = "665f1df013ad4e18f6a11244") private String targetId; @Field("targetType") @JsonProperty("targetType") + @Schema(description = "Reported target type.", example = "POST") private ReportTargetType targetType; @Field("targetAuthorId") @JsonProperty("targetAuthorId") + @Schema(description = "Author id of the reported target.", example = "665f1df013ad4e18f6a11241") private String targetAuthorId; @Field("reason") @JsonProperty("reason") + @Schema(description = "Reporter-selected reason.", example = "MISINFORMATION") private ReportReason reason; @Field("details") @JsonProperty("details") + @Schema(description = "Reporter-provided details.", example = "The inscription description contains misleading attribution.") private String details; @Field("status") @JsonProperty("status") @Indexed + @Schema(description = "Current moderation workflow status.", example = "ESCALATED") private ReportStatus status; @Field("activeReportKey") @@ -88,15 +99,18 @@ public class ModerationReport { @Field("actionTaken") @JsonProperty("actionTaken") @Builder.Default + @Schema(description = "Action applied during moderation.", example = "REMOVE_CONTENT") private ModerationAction actionTaken = ModerationAction.NONE; @Field("resolvedBy") @JsonProperty("resolvedBy") + @Schema(description = "Actor that resolved the report.", example = "moderator@example.com") private String resolvedBy; @Field("aiConfidenceScore") @JsonProperty("aiConfidenceScore") @Builder.Default + @Schema(description = "AI confidence score from 0 to 1.", example = "0.87") private Double aiConfidenceScore = 0.0; @CreatedDate @@ -121,6 +135,7 @@ public class ModerationReport { @Field("auditEntries") @JsonProperty("auditEntries") @Builder.Default + @ArraySchema(schema = @Schema(implementation = ReportAuditEntry.class)) private List auditEntries = new ArrayList<>(); public static String buildActiveReportKey(String reporterId, String targetId, ReportTargetType targetType) { 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 index 679b197..6829bf8 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,17 +16,21 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ReportAuditEntry", description = "Single audit trail entry recorded during report moderation.") public class ReportAuditEntry { @Field("actor") @JsonProperty("actor") + @Schema(description = "Actor email or system actor name.", example = "moderator@example.com") private String actor; @Field("message") @JsonProperty("message") + @Schema(description = "Audit message.", example = "Status -> RESOLVED | Action -> REMOVE_CONTENT | By -> moderator@example.com") private String message; @Field("createdAt") @JsonProperty("createdAt") + @Schema(description = "Audit creation timestamp.", example = "2026-05-19T10:35:00.000+00:00") private Date createdAt; } diff --git a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java index 92d9abb..a7be450 100644 --- a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java +++ b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java @@ -16,14 +16,25 @@ import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.user.dto.UpdateProfileRequest; +import com.cadac.stone_inscription.user.dto.UserProfileResponse; import com.cadac.stone_inscription.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @RestController @RequestMapping("/user") +@Tag(name = "Users", description = "Authenticated user profile and image management APIs.") public class UserController { @Autowired @@ -38,6 +49,16 @@ public class UserController { */ @GetMapping("/profile") @Secured("user") + @Operation( + summary = "Get my profile", + description = "Returns the profile attached to the JWT subject.", + responses = { + @ApiResponse(responseCode = "200", description = "Profile fetched successfully", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Profile fetched successfully\",\"http-status\":\"OK\",\"data\":{\"id\":\"665f1df013ad4e18f6a11244\",\"name\":\"Asha Rao\",\"username\":\"asha_rao\",\"email\":\"asha@example.com\",\"profileImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245\",\"coverImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246\",\"bio\":\"Epigraphy researcher\",\"imagesUploaded\":12,\"upvotesReceived\":34,\"followers\":8,\"points\":240}}"))), + @ApiResponse(responseCode = "401", description = "Token is missing or user cannot be resolved", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getProfile(HttpServletRequest request) { String email = extractEmailFromToken(request); return userService.getProfile(email); @@ -49,6 +70,15 @@ public ResponseEntity getProfile(HttpServletRequest request) { */ @PostMapping("/updateProfile") @Secured("user") + @Operation( + summary = "Update my profile", + description = "Updates the authenticated user's editable profile fields. Bean validation constraints are visible in the request schema.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = UpdateProfileRequest.class), + examples = @ExampleObject(value = "{\"username\":\"inscription_scholar\",\"bio\":\"Epigraphy researcher\"}"))), + responses = @ApiResponse(responseCode = "200", description = "Profile updated successfully", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class)))) public ResponseEntity updateProfile( HttpServletRequest request, @Valid @RequestBody UpdateProfileRequest updateProfileRequest) { @@ -64,8 +94,14 @@ public ResponseEntity updateProfile( */ @PostMapping("/uploadProfileImage") @Secured("user") + @Operation( + summary = "Upload profile image", + description = "Replaces the authenticated user's profile image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Profile image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity uploadProfileImage( HttpServletRequest request, + @Parameter(description = "Profile image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -78,8 +114,14 @@ public ResponseEntity uploadProfileImage( */ @PostMapping("/uploadCoverImage") @Secured("user") + @Operation( + summary = "Upload cover image", + description = "Replaces the authenticated user's cover image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Cover image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity uploadCoverImage( HttpServletRequest request, + @Parameter(description = "Cover image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -91,6 +133,15 @@ public ResponseEntity uploadCoverImage( * GET /api/v1/user/public/images/{id} */ @GetMapping("/public/images/{id}") + @Operation( + summary = "Download user image", + description = "Public endpoint that streams a profile or cover image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getUserImage(@PathVariable String id) { return userService.getUserImage(id); } diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java index a26e50f..d7fe911 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.*; @@ -10,13 +11,16 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UpdateProfileRequest", description = "Editable profile fields for the authenticated user.") public class UpdateProfileRequest { @JsonProperty("username") + @Schema(description = "Unique username. Submit only when changing it.", example = "inscription_scholar", minLength = 3, maxLength = 25) @Size(min = 3, max = 25, message = "Username must be between 3 and 25 characters") private String username; @JsonProperty("bio") + @Schema(description = "Short profile bio. Letters, numbers, and spaces only.", example = "Epigraphy researcher", minLength = 3, maxLength = 150, pattern = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$") @Size(min = 3, max = 150, message = "Bio must be between 3 and 150 characters") @Pattern(regexp = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$", message = "Bio can only contain letters, numbers, and spaces") private String bio; diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java index 74e8c0d..d95fd10 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java @@ -2,44 +2,57 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UserProfile", description = "Profile details returned for the authenticated user.") public class UserProfileResponse { @JsonProperty("id") + @Schema(description = "User identifier.", example = "665f1df013ad4e18f6a11244") private String id; @JsonProperty("name") + @Schema(description = "OAuth provider display name.", example = "Asha Rao") private String name; @JsonProperty("username") + @Schema(description = "Application username.", example = "asha_rao") private String username; @JsonProperty("email") + @Schema(description = "User email address.", example = "asha@example.com", format = "email") private String email; @JsonProperty("profileImage") + @Schema(description = "Public profile image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245") private String profileImage; @JsonProperty("coverImage") + @Schema(description = "Public cover image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246") private String coverImage; @JsonProperty("bio") + @Schema(description = "Short user bio.", example = "Epigraphy researcher") private String bio; @JsonProperty("imagesUploaded") + @Schema(description = "Number of uploaded inscription images.", example = "12") private Integer imagesUploaded; @JsonProperty("upvotesReceived") + @Schema(description = "Total upvotes received on user comments/descriptions.", example = "34") private Integer upvotesReceived; @JsonProperty("followers") + @Schema(description = "Follower count.", example = "8") private Integer followers; @JsonProperty("points") + @Schema(description = "Reputation points.", example = "240") private Integer points; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8bdd83e..a3725b1 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -64,6 +64,16 @@ logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG spring.servlet.multipart.max-file-size=75MB spring.servlet.multipart.max-request-size=1200MB +# OpenAPI / Swagger UI +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.display-request-duration=true +springdoc.swagger-ui.doc-expansion=none +springdoc.swagger-ui.filter=true +springdoc.swagger-ui.operations-sorter=alpha +springdoc.swagger-ui.tags-sorter=alpha +springdoc.writer-with-order-by-keys=true + management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.prometheus.enabled=true management.metrics.export.prometheus.enabled=true From 933d258197f5ff3c40a6b72ae8265486585e08cd Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Wed, 20 May 2026 11:07:05 +0530 Subject: [PATCH 07/28] Done with the admin Oauth system --- .env | 24 ++++++++++++------- .../auth/CustomOAuth2SuccessHandler.java | 1 + .../auth/JwtRequestFilter.java | 10 ++++---- .../StoneinscriptionConfiguration.java | 9 +++---- src/main/resources/application.properties | 9 ++++--- src/main/resources/application.yml | 19 +++++++-------- 6 files changed, 38 insertions(+), 34 deletions(-) diff --git a/.env b/.env index 8b85660..e7760ce 100644 --- a/.env +++ b/.env @@ -2,9 +2,10 @@ # Replace with your actual OAuth provider credentials # Google OAuth2 -GOOGLE_CLIENT_ID=760575932383-1ah7nvt9vi02pr5r20h5nm5g8p1pl87j.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=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 +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret REDIRECT_URL=http://localhost/api/login/oauth2/code +GOOGLE_REDIRECT_URI=http://localhost:8080/login/oauth2/code/{registrationId} # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here FACEBOOK_CLIENT_SECRET=your-facebook-app-secret-here @@ -27,15 +28,20 @@ CONTENT_MODERATION_INSECURE_SSL=true CONTENT_MODERATION_SAFE_THRESHOLD=0.7 CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 -ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com +ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com +APP_CORS_URL=http://localhost:3000 +APP_BACKEND_URL=http://localhost:8080 +APP_FRONTEND_OAUTH_CALLBACK_URL=http://localhost:8080/api/v1/noauth/check +APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/api/v1/noauth/check # Spring Mail -SPRING_MAIL_HOST=smtp.gmail.com -SPRING_MAIL_PORT=587 -SPRING_MAIL_USERNAME=bavithbabu25@gmail.com -SPRING_MAIL_PASSWORD=whul oduf kaew xboe -SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=true -SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=true +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=your-email@example.com +spring.mail.password=your-app-password +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + # CONTENT_MODERATION_INSECURE_SSL=false diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 30e7c6a..bd27e36 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -105,6 +105,7 @@ private UserAuth findOrCreateUser(String email, String name, String picture, Str ObjectId userId = savedUserAuth.getId(); User profile = User.builder() + .username(email) .name(name) .email(email) .profileImage(picture) diff --git a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java index 6cec1ab..62fe691 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java +++ b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java @@ -67,9 +67,9 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } - public String extractJwtFromRequest(HttpServletRequest request) { - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + public String extractJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { // JwtBlackList jwtBlackList = // jwtBlackListRepo.findByJwtToken(bearerToken.substring(7, @@ -84,11 +84,9 @@ public String extractJwtFromRequest(HttpServletRequest request) { // throw new CMPFOException("User Already Loged Out", HttpStatus.UNAUTHORIZED); // } - + } else { return null; } - } - } diff --git a/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java index 2ed5805..4c5e85e 100644 --- a/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java +++ b/src/main/java/com/cadac/stone_inscription/configuration/StoneinscriptionConfiguration.java @@ -100,15 +100,16 @@ public SecurityFilterChain filterChain(HttpSecurity http, .authorizeHttpRequests(authz -> authz .requestMatchers("/api/v1/noauth/**", "/post/public/**").permitAll() - // .requestMatchers("/api/v1/**", "/post/**").authenticated() - .requestMatchers("/api/v1/**", "/post/**").permitAll() + .requestMatchers("/api/v1/**", "/post/**").authenticated() + // .requestMatchers("/api/v1/**", "/post/**").permitAll() .requestMatchers("/oauth2/**", "/oauth2/login/**").permitAll() .anyRequest().permitAll()) .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)) - // ✅ Stateless session (required for JWT) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // OAuth2 login needs temporary state during the redirect round-trip. + // Spring will only create a session when required for that flow. + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8bdd83e..7fed212 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,11 +12,11 @@ spring.data.mongodb.uri=${MONGO_URI} # mongodb://root:password123@mongodb-primary:27017,mongodb-secondary:27017/StoneInscription?replicaSet=replicaset&authSource=admin # CORS configuration -app.cors.url=https://inscriptions.cdacb.in +app.cors.url=${APP_CORS_URL:https://inscriptions.cdacb.in} -app.backend.url= https://inscriptions.cdacb.in/api -app.frontend.oauth.callback-url=https://inscriptions.cdacb.in/oauth/callback -app.frontend.admin.approval-result-url=https://inscriptions.cdacb.in/admin/approval-result +app.backend.url=${APP_BACKEND_URL:https://inscriptions.cdacb.in/api} +app.frontend.oauth.callback-url=${APP_FRONTEND_OAUTH_CALLBACK_URL:https://inscriptions.cdacb.in/oauth/callback} +app.frontend.admin.approval-result-url=${APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL:https://inscriptions.cdacb.in/admin/approval-result} admin.approval.token-validity-ms=${ADMIN_APPROVAL_TOKEN_VALIDITY_MS:86400000} geolocation.api.url=https://nominatim.openstreetmap.org/reverse @@ -51,7 +51,6 @@ logging.level.org.springframework.security.web.FilterChainProxy=TRACE # OAuth2 specific components logging.level.org.springframework.security.oauth2.client=DEBUG logging.level.org.springframework.security.oauth2.core=DEBUG - # Include stack traces and method names in logs logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%logger{36}:%line] - %msg%n logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%logger{36}:%line] - %msg%n diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2cf222d..77d2ec8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -58,16 +58,15 @@ spring: client: registration: # Google OAuth2 Configuration - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - openid - - profile - - email - redirect-uri: "https://inscriptions.cdacb.in/api/login/oauth2/code/{registrationId}" - # redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - # Facebook OAuth2 Configuration + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - openid + - profile + - email + redirect-uri: ${GOOGLE_REDIRECT_URI:https://inscriptions.cdacb.in/api/login/oauth2/code/{registrationId}} + # Facebook OAuth2 Configuration facebook: client-id: ${FACEBOOK_CLIENT_ID} client-secret: ${FACEBOOK_CLIENT_SECRET} From 6ad8e8611a4de6226ed4985db64fa7f5ffd6f888 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Wed, 20 May 2026 11:37:58 +0530 Subject: [PATCH 08/28] done with the admin work flow --- .../stone_inscription/auth/JwtRequestFilter.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java index 62fe691..d0ac56e 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java +++ b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java @@ -3,7 +3,6 @@ import java.io.IOException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; @@ -85,8 +84,9 @@ public String extractJwtFromRequest(HttpServletRequest request) { // } - } else { - return null; - } - } -} + } else { + + return null; + } + } +} From 4934343d43a71b2e4781bbaf794b3d18ea2b7e08 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Wed, 20 May 2026 11:40:13 +0530 Subject: [PATCH 09/28] Fixed few more buges in this branch --- .../com/cadac/stone_inscription/auth/JwtRequestFilter.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java index d0ac56e..e098586 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java +++ b/src/main/java/com/cadac/stone_inscription/auth/JwtRequestFilter.java @@ -3,6 +3,7 @@ import java.io.IOException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.User; @@ -85,8 +86,8 @@ public String extractJwtFromRequest(HttpServletRequest request) { // } } else { - - return null; + throw new StoneInscriptionException("Invalid Token Request Bearer not found ", HttpStatus.BAD_REQUEST); + // return null; } } } From ee108d15050053c68b3ed8297e1f149b1547c66c Mon Sep 17 00:00:00 2001 From: nayan458 Date: Tue, 19 May 2026 14:30:50 +0530 Subject: [PATCH 10/28] config: configured open API for autogenerate type defination --- pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pom.xml b/pom.xml index a6a6311..c95efaf 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,13 @@ spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + org.springframework.boot From 5e38f24960d0695d32aff4996d0842dee45e94fd Mon Sep 17 00:00:00 2001 From: nayan458 Date: Tue, 19 May 2026 16:56:55 +0530 Subject: [PATCH 11/28] local swager setup done --- .../cadac/stone_inscription/OAuthDemo.java | 102 +++++++------ .../api/dto/AccessTokenResponse.java | 18 +++ .../api/dto/ApiErrorResponse.java | 44 ++++++ .../api/dto/ApiSuccessResponse.java | 42 ++++++ .../api/dto/DashboardCountsResponse.java | 62 ++++++++ .../api/dto/ReportQueuedResponse.java | 51 +++++++ .../auth/controller/OAuthController.java | 64 +++++++- .../configuration/OpenApiConfiguration.java | 134 +++++++++++++++++ .../dto/ContentModerationRequestDto.java | 5 + .../dto/ContentModerationResponseDto.java | 12 ++ .../post/controller/PostController.java | 138 +++++++++++++++++- .../post/dto/InscriptionPostDto.java | 106 ++++++++------ .../dto/PublicPostUserDescriptionDto.java | 69 +++++---- .../report/controller/ReportController.java | 50 +++++++ .../report/dto/CreateReportRequest.java | 6 + .../report/dto/ModerateReportRequest.java | 4 + .../report/entity/ModerationReport.java | 15 ++ .../report/entity/ReportAuditEntry.java | 5 + .../user/controller/UserController.java | 51 +++++++ .../user/dto/UpdateProfileRequest.java | 4 + .../user/dto/UserProfileResponse.java | 13 ++ src/main/resources/application.properties | 10 ++ 22 files changed, 868 insertions(+), 137 deletions(-) create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java create mode 100644 src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java diff --git a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java index af6cdd8..f59cc7e 100644 --- a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java +++ b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java @@ -4,50 +4,62 @@ import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@RestController -@RequestMapping("/api/v1") -public class OAuthDemo { - - @GetMapping("/") - public String getMethodName(@RequestParam String param) { - return "hello "; - } - - @Secured( "user" ) - @GetMapping("/fail") - public String getfail() { - return "hello fail "; - } - - @GetMapping("/noauth/check") - public String noAuth() { - return "This endpoint does not require authentication."; - } - - // Direct Facebook login endpoint - @GetMapping("/auth/facebook") - public String facebookLogin() { - return "redirect:/oauth2/authorization/facebook"; - } - - @Secured({ "admin" }) - @GetMapping("/auth") - public String auth() { - return "This endpoint requires authentication."; - } - - @GetMapping("/auth/token") - public String authToken() { - return "Authentication successful. You can now access secured endpoints."; - } - - @Secured({ "user", "admin" }) - @GetMapping("/authrole") - public String authRole() { - return "This endpoint requires authentication with role."; - } +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "Authentication", description = "OAuth smoke-test and role-check endpoints.") +public class OAuthDemo { + + @GetMapping("/") + @Operation(summary = "API smoke test", description = "Returns a minimal success message for connectivity checks.") + public String getMethodName(@Parameter(description = "Echo parameter placeholder.", example = "ping") @RequestParam String param) { + return "hello "; + } + + @Secured( "user" ) + @GetMapping("/fail") + @Operation(summary = "User role check", description = "Requires a user role and returns a simple role-check message.") + public String getfail() { + return "hello fail "; + } + + @GetMapping("/noauth/check") + @Operation(summary = "Public auth health check", description = "Confirms that public no-auth routes are reachable.") + public String noAuth() { + return "This endpoint does not require authentication."; + } + + // Direct Facebook login endpoint + @GetMapping("/auth/facebook") + @Operation(summary = "Facebook login redirect marker", description = "Legacy endpoint that returns the Facebook OAuth redirect target as text.") + public String facebookLogin() { + return "redirect:/oauth2/authorization/facebook"; + } + + @Secured({ "admin" }) + @GetMapping("/auth") + @Operation(summary = "Admin auth check", description = "Requires an admin role and returns a simple authenticated message.") + public String auth() { + return "This endpoint requires authentication."; + } + + @GetMapping("/auth/token") + @Operation(summary = "Token success marker", description = "Returns a text message used after successful authentication flows.") + public String authToken() { + return "Authentication successful. You can now access secured endpoints."; + } + + @Secured({ "user", "admin" }) + @GetMapping("/authrole") + @Operation(summary = "User or admin role check", description = "Requires either user or admin role.") + public String authRole() { + return "This endpoint requires authentication with role."; + } } diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java new file mode 100644 index 0000000..3b06ed6 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java @@ -0,0 +1,18 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AccessTokenResponse", description = "Access token payload returned after refresh-token rotation.") +public class AccessTokenResponse { + + @Schema(description = "New short-lived JWT access token.", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuser@example.com\"") + private String accessToken; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java new file mode 100644 index 0000000..a255791 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java @@ -0,0 +1,44 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiErrorResponse", description = "Standard error body returned by API exception handlers.") +public class ApiErrorResponse { + + @Schema(description = "Human-readable error message.", example = "Invalid or missing authorization token") + @JsonProperty("error_message") + private String errorMessage; + + @Schema(description = "HTTP status reason or numeric status used by the handler.", example = "UNAUTHORIZED") + @JsonProperty("http_status") + private Object httpStatus; + + @Schema(description = "HTTP status code.", example = "401") + @JsonProperty("http_status_code") + private Integer httpStatusCode; + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public Integer getHttpStatusCode() { + return httpStatusCode; + } + + public void setHttpStatusCode(Integer httpStatusCode) { + this.httpStatusCode = httpStatusCode; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java new file mode 100644 index 0000000..783f6e4 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java @@ -0,0 +1,42 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiSuccessResponse", description = "Standard success envelope returned by most JSON endpoints.") +public class ApiSuccessResponse { + + @Schema(description = "Operation result message.", example = "Profile fetched successfully") + private String message; + + @Schema(description = "HTTP status reason used by the response envelope.", example = "OK") + @JsonProperty("http-status") + private Object httpStatus; + + @Schema(description = "Endpoint-specific payload.") + private T data; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java new file mode 100644 index 0000000..705f3cf --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java @@ -0,0 +1,62 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DashboardCountsResponse", description = "Public aggregate counters for the application dashboard.") +public class DashboardCountsResponse { + + @Schema(description = "Registered users.", example = "128") + private Integer totalUsers; + + @Schema(description = "Published posts.", example = "42") + private Integer totalPosts; + + @Schema(description = "Uploaded image objects.", example = "280") + private Integer totalImages; + + @Schema(description = "Posts with extracted geolocation metadata.", example = "31") + private Integer totalGeoTaggedPosts; + + @Schema(description = "Posts with an English translation available.", example = "17") + private Integer totalTranslations; + + public Integer getTotalUsers() { + return totalUsers; + } + + public void setTotalUsers(Integer totalUsers) { + this.totalUsers = totalUsers; + } + + public Integer getTotalPosts() { + return totalPosts; + } + + public void setTotalPosts(Integer totalPosts) { + this.totalPosts = totalPosts; + } + + public Integer getTotalImages() { + return totalImages; + } + + public void setTotalImages(Integer totalImages) { + this.totalImages = totalImages; + } + + public Integer getTotalGeoTaggedPosts() { + return totalGeoTaggedPosts; + } + + public void setTotalGeoTaggedPosts(Integer totalGeoTaggedPosts) { + this.totalGeoTaggedPosts = totalGeoTaggedPosts; + } + + public Integer getTotalTranslations() { + return totalTranslations; + } + + public void setTotalTranslations(Integer totalTranslations) { + this.totalTranslations = totalTranslations; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java new file mode 100644 index 0000000..c8e3398 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java @@ -0,0 +1,51 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "ReportQueuedResponse", description = "Payload returned after a report is accepted for asynchronous moderation.") +public class ReportQueuedResponse { + + @Schema(description = "Published report event identifier.", example = "7f8b7d33-16f2-4e84-9f54-8085a9e84791") + private String eventId; + + @Schema(description = "Identifier of the reported content or user.", example = "665f1df013ad4e18f6a11244") + private String targetId; + + @Schema(description = "Reported resource type.", example = "POST") + private String targetType; + + @Schema(description = "Queue processing state.", example = "QUEUED") + private String status; + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public String getTargetType() { + return targetType; + } + + public void setTargetType(String targetType) { + this.targetType = targetType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index d83ad5b..2d2fc05 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.admin.service.AdminAccessService; +import com.cadac.stone_inscription.api.dto.AccessTokenResponse; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.auth.OAuthFlowCookieService; import com.cadac.stone_inscription.auth.OAuthFlowType; import com.cadac.stone_inscription.auth.JwtUtil; @@ -26,12 +29,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; - -@RestController -@RequestMapping("/oauth2") - + +@RestController +@RequestMapping("/oauth2") +@Tag(name = "Authentication", description = "OAuth login redirects, refresh-token rotation, session activity, and logout.") + public class OAuthController { @Autowired @@ -48,21 +59,41 @@ public class OAuthController { @PostMapping("/logout") + @Operation( + summary = "Logout", + description = "Revokes the refresh token cookie when present and expires the browser cookie.", + responses = @ApiResponse(responseCode = "200", description = "Logged out", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Logged out successfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { return stoneAuthService.logoutAuth(request, response); } - @PostMapping("/authenticated/refresh-token") - public ResponseEntity refreshToken(HttpServletResponse response, HttpServletRequest request) + @PostMapping("/authenticated/refresh-token") + @Operation( + summary = "Refresh access token", + description = "Rotates the HTTP-only refresh token cookie and returns a new JWT access token.", + responses = { + @ApiResponse(responseCode = "200", description = "Token refreshed", + content = @Content(schema = @Schema(implementation = AccessTokenResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Sucessfully updated\",\"http-status\":\"OK\",\"data\":{\"accessToken\":\"eyJhbGciOiJIUzI1NiJ9...\"}}"))), + @ApiResponse(responseCode = "401", description = "Refresh token is missing, revoked, or expired", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + public ResponseEntity refreshToken(HttpServletResponse response, HttpServletRequest request) throws UsernameNotFoundException, JOSEException { return stoneAuthService.refreshToken(request, response); } - @PostMapping("/authenticated/active") - public ResponseEntity updateLastActive(HttpServletRequest request) { + @PostMapping("/authenticated/active") + @Operation( + summary = "Mark session active", + description = "Refreshes last-use time for the refresh-token session. Returns no content on success.", + responses = @ApiResponse(responseCode = "204", description = "Session marked active")) + public ResponseEntity updateLastActive(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { throw new StoneInscriptionException("Unauthorized", HttpStatus.UNAUTHORIZED); @@ -81,7 +112,12 @@ public ResponseEntity updateLastActive(HttpServletRequest request) { } @GetMapping("/login/{provider}") + @Operation( + summary = "Start user OAuth login", + description = "Stores the user-login flow marker and redirects to the configured OAuth provider.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to OAuth provider")) public void loginWithProvider( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.USER_LOGIN); @@ -89,12 +125,15 @@ public void loginWithProvider( } @GetMapping("/login") + @Operation(summary = "Start default user OAuth login", description = "Redirects to the configured default OAuth provider.") public void login(HttpServletResponse response) throws IOException { loginWithProvider(defaultProvider, response); } @GetMapping("/admin/register/{provider}") + @Operation(summary = "Start admin registration", description = "Starts OAuth flow for an admin registration request.") public void adminRegister( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_REGISTER); @@ -102,12 +141,15 @@ public void adminRegister( } @GetMapping("/admin/register") + @Operation(summary = "Start default admin registration", description = "Starts admin registration using the configured default OAuth provider.") public void adminRegisterDefault(HttpServletResponse response) throws IOException { adminRegister(defaultProvider, response); } @GetMapping("/admin/login/{provider}") + @Operation(summary = "Start admin OAuth login", description = "Starts OAuth login for an approved admin account.") public void adminLogin( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_LOGIN); @@ -115,12 +157,18 @@ public void adminLogin( } @GetMapping("/admin/login") + @Operation(summary = "Start default admin OAuth login", description = "Starts admin login using the configured default OAuth provider.") public void adminLoginDefault(HttpServletResponse response) throws IOException { adminLogin(defaultProvider, response); } @GetMapping("/admin/approve") + @Operation( + summary = "Approve admin request", + description = "Consumes an emailed approval token and redirects the browser to the configured approval-result page.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to approval result page")) public void approveAdminRequest( + @Parameter(description = "Admin approval token.", required = true) @RequestParam("token") String token, HttpServletResponse response) throws IOException { try { diff --git a/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java new file mode 100644 index 0000000..de35f49 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java @@ -0,0 +1,134 @@ +package com.cadac.stone_inscription.configuration; + +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.method.HandlerMethod; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +@Configuration +@SecurityScheme( + name = OpenApiConfiguration.BEARER_AUTH, + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER) +public class OpenApiConfiguration { + + public static final String BEARER_AUTH = "bearerAuth"; + + private static final String ERROR_SCHEMA_REF = "#/components/schemas/ApiErrorResponse"; + + @Bean + public OpenAPI stoneInscriptionOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Stone Inscription API") + .version("v1") + .description(""" + REST API for OAuth authentication, user profiles, inscription posts, images, public dashboard metrics, and moderation reports. + + Most JSON endpoints return a standard envelope with `message`, `http-status`, and `data`. Protected endpoints use a JWT bearer token in the `Authorization` header. + """) + .contact(new Contact().name("C-DAC Stone Inscription")) + .license(new License().name("Internal"))) + .components(new Components()); + } + + @Bean + public GroupedOpenApi authenticationApi() { + return GroupedOpenApi.builder() + .group("01-authentication") + .displayName("Authentication") + .pathsToMatch("/oauth2/**", "/api/v1/**") + .pathsToExclude("/api/v1/") + .build(); + } + + @Bean + public GroupedOpenApi userApi() { + return GroupedOpenApi.builder() + .group("02-users") + .displayName("Users") + .pathsToMatch("/user/**") + .build(); + } + + @Bean + public GroupedOpenApi postApi() { + return GroupedOpenApi.builder() + .group("03-posts") + .displayName("Posts") + .pathsToMatch("/post/**") + .pathsToExclude("/post/test/**") + .build(); + } + + @Bean + public GroupedOpenApi reportApi() { + return GroupedOpenApi.builder() + .group("04-reports") + .displayName("Reports") + .pathsToMatch("/report", "/reports", "/moderate/**") + .pathsToExclude("/test/**") + .build(); + } + + @Bean + public GlobalOperationCustomizer commonApiResponsesCustomizer() { + return (operation, handlerMethod) -> { + if (operation.getResponses() == null) { + operation.setResponses(new ApiResponses()); + } + + if (isSecured(handlerMethod)) { + operation.addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); + addErrorResponse(operation, "401", "JWT is missing, expired, or invalid."); + addErrorResponse(operation, "403", "Authenticated user does not have the required role."); + } + + addErrorResponse(operation, "400", "Request is syntactically valid but violates business rules."); + addErrorResponse(operation, "422", "Bean validation failed for request body, form, or query parameters."); + addErrorResponse(operation, "500", "Unexpected server error."); + + return operation; + }; + } + + private boolean isSecured(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(Secured.class) + || handlerMethod.getBeanType().isAnnotationPresent(Secured.class); + } + + private void addErrorResponse(Operation operation, String code, String description) { + if (operation.getResponses().containsKey(code)) { + return; + } + + operation.getResponses().addApiResponse(code, new ApiResponse() + .description(description) + .content(jsonContent(ERROR_SCHEMA_REF))); + } + + private Content jsonContent(String schemaRef) { + return new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(new Schema<>().$ref(schemaRef))); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java index 4ec8632..4ae2759 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,14 +12,18 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ContentModerationRequest", description = "Text fields sent to the content moderation workflow.") public class ContentModerationRequestDto { @JsonProperty("title") + @Schema(description = "Content title.", example = "Ashokan pillar fragment") private String title; @JsonProperty("topic") + @Schema(description = "Content topic.", example = "Temple inscription") private String topic; @JsonProperty("description") + @Schema(description = "Content body to moderate.", example = "Fragmentary stone inscription found near the temple entrance.") private String description; } 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 ef985d0..c1bf026 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 @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -14,40 +15,51 @@ @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) +@Schema(name = "ContentModerationResponse", description = "Normalized moderation webhook response.") public class ContentModerationResponseDto { @JsonProperty("timestamp") + @Schema(description = "Webhook timestamp.", example = "2026-05-19T10:35:00Z") private String timestamp; @JsonProperty("decision") @JsonAlias({ "verdict", "action" }) + @Schema(description = "Moderation decision.", example = "ALLOW") private String decision; @JsonProperty("label") @JsonAlias({ "category", "classification" }) + @Schema(description = "Moderation category or label.", example = "safe") private String label; @JsonProperty("confidence") @JsonAlias({ "score", "confidenceScore", "confidence_score", "probability" }) + @Schema(description = "Confidence score from 0 to 1 when supplied.", example = "0.91") private Double confidence; @JsonProperty("reason") @JsonAlias({ "message", "explanation" }) + @Schema(description = "Human-readable moderation reason.", example = "No unsafe content detected") private String reason; @JsonProperty("status") @JsonAlias({ "state" }) + @Schema(description = "Webhook processing status.", example = "APPROVED") private String status; @JsonProperty("description") + @Schema(description = "Moderated content description returned by the workflow.") private String description; @JsonProperty("id") + @Schema(description = "Webhook-side moderation id.", example = "5006") private Long id; @JsonProperty("createdAt") + @Schema(description = "Webhook record creation timestamp.", example = "2026-05-19T10:35:00Z") private String createdAt; @JsonProperty("updatedAt") + @Schema(description = "Webhook record update timestamp.", example = "2026-05-19T10:35:01Z") private String updatedAt; } 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 320f251..111057b 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 @@ -21,14 +21,26 @@ import org.springframework.web.multipart.MultipartFile; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.DashboardCountsResponse; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.post.dto.InscriptionPostDto; import com.cadac.stone_inscription.post.service.PostService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping("/post") +@Tag(name = "Posts", description = "Inscription post, image, description, rating, and dashboard APIs.") public class PostController { private static final int MAX_IMAGES_PER_POST = 16; @@ -45,9 +57,17 @@ public class PostController { @PostMapping("/addPostWithFile") @Secured("user") + @Operation( + summary = "Create post with images", + description = "Creates an inscription post from multipart metadata and one or more images. The server validates extension, image count, size, metadata, geolocation, perceptual hash data, and content moderation.", + responses = @ApiResponse(responseCode = "200", description = "Post images uploaded", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Images Uploaded Sucessfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity addPostWithFile( + @Parameter(description = "Post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, HttpServletRequest request, + @Parameter(description = "Image files. Maximum 16 files, 75 MB each.", required = true) @RequestPart("files") MultipartFile... files) throws IOException { files = getNonEmptyFiles(files); @@ -68,6 +88,11 @@ public ResponseEntity addPostWithFile( @PostMapping("/getAllPost") @Secured("user") + @Operation( + summary = "List all visible posts", + description = "Returns all posts with image identifiers expanded to public image URLs.", + responses = @ApiResponse(responseCode = "200", description = "Posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getAllPost() { return postService.getAllPost(); @@ -75,6 +100,15 @@ public ResponseEntity getAllPost() { } @GetMapping("/public/images/{id}") + @Operation( + summary = "Download post image", + description = "Public endpoint that streams a stored inscription image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getImage(@PathVariable String id) { return postService.getImages(id); @@ -83,6 +117,11 @@ public ResponseEntity getImage(@PathVariable String id) { @PostMapping("/getAllUserPost") @Secured("user") + @Operation( + summary = "List my posts", + description = "Returns posts created by the authenticated user with image URLs hydrated.", + responses = @ApiResponse(responseCode = "200", description = "User posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getAllUserPost(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -97,8 +136,14 @@ public ResponseEntity getAllUserPost(HttpServletRequest request) { @PostMapping("/addPoastDiscription") @Secured("user") - public ResponseEntity addPoastDiscription(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("discription") String discription) { + @Operation( + summary = "Add post description", + description = "Adds a user-authored description/comment to a post after content moderation. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addPoastDiscription(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Description text.", example = "This line appears to mention a land grant.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -112,7 +157,13 @@ public ResponseEntity addPoastDiscription(HttpServletRequest request, @Reques @PostMapping("/getPostDiscription") @Secured("user") - public ResponseEntity getPostDiscription(@RequestParam("postId") String postId) { + @Operation( + summary = "List post descriptions", + description = "Returns all user descriptions/comments for a post. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity getPostDiscription( + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { return postService.getPostDiscription( postId); @@ -121,8 +172,15 @@ public ResponseEntity getPostDiscription(@RequestParam("postId") String postI @PostMapping("/updatePostDiscription") @Secured("user") + @Operation( + summary = "Update post description", + description = "Updates an authenticated user's own description/comment after moderation.", + responses = @ApiResponse(responseCode = "200", description = "Description updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity updatePostDiscription(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("discriptionId") String discriptionId, + @Parameter(description = "Updated description text.", example = "Updated historical reading.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -136,8 +194,14 @@ public ResponseEntity updatePostDiscription(HttpServletRequest request, @PostMapping("/addRating") @Secured("user") - public ResponseEntity addRating(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("rating") Double rating) { + @Operation( + summary = "Rate post", + description = "Adds or updates the authenticated user's numeric rating for a post.", + responses = @ApiResponse(responseCode = "200", description = "Rating added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addRating(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Rating value.", example = "4.5") @RequestParam("rating") Double rating) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -151,7 +215,13 @@ public ResponseEntity addRating(HttpServletRequest request, @RequestParam("po @PostMapping("/addVote") @Secured("user") - public ResponseEntity addVote(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Toggle description vote", + description = "Adds an upvote when the authenticated user has not voted, or removes the existing vote.", + responses = @ApiResponse(responseCode = "200", description = "Vote updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity addVote(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -165,6 +235,11 @@ public ResponseEntity addVote(HttpServletRequest request, @RequestParam("desc @PostMapping("/userProfile") @Secured("user") + @Operation( + summary = "Get current user entity profile", + description = "Legacy post-module profile endpoint that returns the authenticated user object in the standard envelope.", + responses = @ApiResponse(responseCode = "200", description = "User profile fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity userProfile(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -177,7 +252,13 @@ public ResponseEntity userProfile(HttpServletRequest request) { @PostMapping("/postDelete") @Secured("user") - public ResponseEntity postDelete(HttpServletRequest request, @RequestParam("postId") String postId) { + @Operation( + summary = "Delete my post", + description = "Deletes a post owned by the authenticated user and archives related content according to the content delete service.", + responses = @ApiResponse(responseCode = "200", description = "Post deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity postDelete(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -190,7 +271,13 @@ public ResponseEntity postDelete(HttpServletRequest request, @RequestParam("p @PostMapping("/discriptionDelete") @Secured("user") - public ResponseEntity descriptionDelete(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Delete my description", + description = "Deletes a description/comment owned by the authenticated user. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity descriptionDelete(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -204,10 +291,19 @@ public ResponseEntity descriptionDelete(HttpServletRequest request, @RequestP // Helps logged user to create a post and upload images @PostMapping("/updatePost") @Secured("user") + @Operation( + summary = "Update post metadata and images", + description = "Updates post metadata, removes selected images, adds new images, and enforces that at least one image remains and no more than 16 images are attached.", + responses = @ApiResponse(responseCode = "200", description = "Post updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity updatePost(HttpServletRequest request, + @Parameter(description = "Updated post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to remove from the post.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds", required = false) List deletedImageIds, + @Parameter(description = "New image files to add.", required = false) @RequestPart(value = "files", required = false) MultipartFile... files) { files = getNonEmptyFiles(files); @@ -225,8 +321,15 @@ public ResponseEntity updatePost(HttpServletRequest request, @PostMapping("/addImagesToPost") @Secured("user") + @Operation( + summary = "Add images to post", + description = "Adds one or more images to an owned post while enforcing extension, size, and max image-count rules.", + responses = @ApiResponse(responseCode = "200", description = "Images added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity addImagesToPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image files. Maximum 16 images per post total.", required = true) @RequestPart("files") MultipartFile... files) { files = getNonEmptyFiles(files); @@ -247,8 +350,15 @@ public ResponseEntity addImagesToPost(HttpServletRequest request, @PostMapping("/deleteImagesFromPost") @Secured("user") + @Operation( + summary = "Delete post images", + description = "Deletes selected images from an owned post while ensuring the post still has at least one image.", + responses = @ApiResponse(responseCode = "200", description = "Images deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity deleteImagesFromPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to delete.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds") List deletedImageIds) { String token = request.getHeader("Authorization"); @@ -279,6 +389,7 @@ public ResponseEntity deleteImagesFromPost(HttpServletRequest request, // } @PostMapping("/test/addPostWithFile/{email}") + @Hidden public ResponseEntity addPostWithFileForTest( @PathVariable String email, @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, @@ -375,6 +486,11 @@ private void validateFiles(MultipartFile[] files, int maxFilesAllowed) { @PostMapping("/getCommentByUser") // @Secured("user") + @Operation( + summary = "List my descriptions", + description = "Returns descriptions/comments authored by the authenticated user with post preview image URLs.", + responses = @ApiResponse(responseCode = "200", description = "User descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity getCommentByUser(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -388,6 +504,12 @@ public ResponseEntity getCommentByUser(HttpServletRequest request) { @GetMapping("/public/getDashboardCounts") // @Secured("user") + @Operation( + summary = "Get public dashboard counts", + description = "Returns public aggregate counts used by the dashboard.", + responses = @ApiResponse(responseCode = "200", description = "Dashboard counts fetched", + content = @Content(schema = @Schema(implementation = DashboardCountsResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Dashboard Counts\",\"http-status\":\"OK\",\"data\":{\"totalUsers\":128,\"totalPosts\":42,\"totalImages\":280,\"totalGeoTaggedPosts\":31,\"totalTranslations\":17}}")))) public ResponseEntity getDashboardCounts() { return postService.getDashboardCounts(); diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java index 3bd2532..727e0db 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java @@ -1,56 +1,70 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class InscriptionPostDto { - - @JsonProperty("description") - private DescriptionDto description; - - @JsonProperty("topic") - private String topic; - - @JsonProperty("script") - private List script; - - @JsonProperty("type") - private String type; - - @JsonProperty("visiblity") - private Boolean visiblity; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "InscriptionPost", description = "Post metadata submitted with one or more inscription images in multipart requests.") +public class InscriptionPostDto { + + @JsonProperty("description") + @Schema(description = "Localized inscription description and language metadata.") + private DescriptionDto description; + + @JsonProperty("topic") + @Schema(description = "High-level post topic.", example = "Temple inscription") + private String topic; + + @JsonProperty("script") + @ArraySchema(schema = @Schema(description = "Script family used in the inscription.", example = "Brahmi")) + private List script; + + @JsonProperty("type") + @Schema(description = "Content type or category.", example = "STONE_INSCRIPTION") + private String type; + + @JsonProperty("visiblity") + @Schema(description = "Whether the post should be visible publicly. Field name preserves the existing API spelling.", example = "true") + private Boolean visiblity; @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DescriptionDto { - - @JsonProperty("title") - private String title; - - @JsonProperty("subject") - private String subject; - - @JsonProperty("description") - private String description; - - @JsonProperty("scriptLanguage") - private List scriptLanguage; - - @JsonProperty("language") - private List language; - - } + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "InscriptionPostDescription", description = "Human-readable inscription description payload.") + public static class DescriptionDto { + + @JsonProperty("title") + @Schema(description = "Display title for the inscription.", example = "Ashokan pillar fragment") + private String title; + + @JsonProperty("subject") + @Schema(description = "Primary subject of the inscription.", example = "Donation record") + private String subject; + + @JsonProperty("description") + @Schema(description = "Detailed inscription description.", example = "Fragmentary stone inscription found near the temple entrance.") + private String description; + + @JsonProperty("scriptLanguage") + @ArraySchema(schema = @Schema(description = "Script language.", example = "Prakrit")) + private List scriptLanguage; + + @JsonProperty("language") + @ArraySchema(schema = @Schema(description = "Readable language.", example = "Hindi")) + private List language; + + } } diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java index 40068df..a912f24 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java @@ -1,25 +1,30 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PublicPostUserDescriptionDto { - - @JsonProperty("id") - private String id; - - @JsonProperty("postId") - private String postId; - - @JsonProperty("userId") - private String userId; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "PublicPostUserDescription", description = "Description/comment authored by a user with voting and image preview details.") +public class PublicPostUserDescriptionDto { + + @JsonProperty("id") + @Schema(description = "Description identifier.", example = "665f1df013ad4e18f6a11247") + private String id; + + @JsonProperty("postId") + @Schema(description = "Associated post identifier.", example = "665f1df013ad4e18f6a11244") + private String postId; + + @JsonProperty("userId") + @Schema(description = "Author user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; @JsonProperty("username") private String username; @@ -30,11 +35,13 @@ public class PublicPostUserDescriptionDto { @JsonProperty("postImageUrl") private String postImageUrl; - @JsonProperty("description") - private String description; - - @JsonProperty("upvote") - private Integer upvote; + @JsonProperty("description") + @Schema(description = "Description text.", example = "The inscription appears to reference a land grant.") + private String description; + + @JsonProperty("upvote") + @Schema(description = "Current upvote count.", example = "7") + private Integer upvote; @JsonProperty("userVote") private List userVote; @@ -45,12 +52,14 @@ public class PublicPostUserDescriptionDto { @JsonProperty("updatedAt") private Date updatedAt; - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class UserVoteDto { - @JsonProperty("userId") - private String userId; - } -} + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "UserVote", description = "User vote marker for a description.") + public static class UserVoteDto { + @JsonProperty("userId") + @Schema(description = "Voting user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; + } +} 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 index 34d6723..79d67da 100644 --- a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java +++ b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java @@ -11,19 +11,33 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.ReportQueuedResponse; 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.service.ReportService; import com.cadac.stone_inscription.report.service.ReportSubmissionService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor +@Tag(name = "Reports", description = "User reporting and moderator review workflow.") public class ReportController { private final ReportService reportService; @@ -32,6 +46,20 @@ public class ReportController { @PostMapping("/report") @Secured({ "user", "admin" }) + @Operation( + summary = "Submit report", + description = "Accepts a report from the authenticated user, validates target/reporting rules, and queues the report for AI moderation.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = CreateReportRequest.class), + examples = @ExampleObject(value = "{\"targetType\":\"POST\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"reason\":\"MISINFORMATION\",\"details\":\"The description attributes the inscription to the wrong dynasty.\"}"))), + responses = { + @ApiResponse(responseCode = "202", description = "Report queued", + content = @Content(schema = @Schema(implementation = ReportQueuedResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Report submitted for AI moderation\",\"http-status\":\"ACCEPTED\",\"data\":{\"eventId\":\"7f8b7d33-16f2-4e84-9f54-8085a9e84791\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"targetType\":\"POST\",\"status\":\"QUEUED\"}}"))), + @ApiResponse(responseCode = "400", description = "Invalid target, duplicate report, or self-report", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity createReport( HttpServletRequest request, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -40,6 +68,7 @@ public ResponseEntity createReport( } @PostMapping("/test/report/{email}") + @Hidden public ResponseEntity createReportForTest( @PathVariable String email, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -49,14 +78,21 @@ public ResponseEntity createReportForTest( @GetMapping("/reports") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "List moderation reports", + description = "Returns moderation reports ordered by creation time. Moderators may optionally filter by status.", + responses = @ApiResponse(responseCode = "200", description = "Reports fetched", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ModerationReport.class))))) public ResponseEntity getReports( HttpServletRequest request, + @Parameter(description = "Optional report status filter.", example = "ESCALATED") @RequestParam(required = false) ReportStatus status) { return reportService.getReports(extractEmailFromToken(request), status); } @GetMapping("/test/reports/{email}") + @Hidden public ResponseEntity getReportsForTest( @PathVariable String email, @RequestParam(required = false) ReportStatus status) { @@ -66,6 +102,19 @@ public ResponseEntity getReportsForTest( @PostMapping("/moderate/{id}") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "Moderate report", + description = "Runs the moderation chain for a report. Human moderator roles are required when an escalated report needs final resolution.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = false, + content = @Content(schema = @Schema(implementation = ModerateReportRequest.class), + examples = @ExampleObject(value = "{\"action\":\"REMOVE_CONTENT\",\"note\":\"Removed after human review.\"}"))), + responses = { + @ApiResponse(responseCode = "200", description = "Report moderation completed", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class))), + @ApiResponse(responseCode = "404", description = "Report not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity moderateReport( HttpServletRequest request, @PathVariable String id, @@ -75,6 +124,7 @@ public ResponseEntity moderateReport( } @PostMapping("/test/moderate/{id}/{email}") + @Hidden public ResponseEntity moderateReportForTest( @PathVariable String id, @PathVariable String email, 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 index 2390801..85ffb0b 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java @@ -3,23 +3,29 @@ import com.cadac.stone_inscription.report.enums.ReportReason; import com.cadac.stone_inscription.report.enums.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "CreateReportRequest", description = "Request used by authenticated users to report a post, comment, or user.") public class CreateReportRequest { + @Schema(description = "Type of resource being reported.", example = "POST", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportTargetType targetType; + @Schema(description = "MongoDB ObjectId or canonical identifier of the reported resource.", example = "665f1df013ad4e18f6a11244", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank private String targetId; + @Schema(description = "Reason selected by the reporter.", example = "MISINFORMATION", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportReason reason; + @Schema(description = "Reporter-provided context for moderators. Maximum 1000 characters.", example = "The inscription description contains misleading historical attribution.", maxLength = 1000, requiredMode = Schema.RequiredMode.REQUIRED) @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 index 35e26c5..8ce742d 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java @@ -2,14 +2,18 @@ import com.cadac.stone_inscription.report.enums.ModerationAction; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "ModerateReportRequest", description = "Optional instruction supplied by a moderator when processing a report.") public class ModerateReportRequest { + @Schema(description = "Moderation action to apply. If omitted, the moderation chain decides the next transition.", example = "REMOVE_CONTENT") private ModerationAction action; + @Schema(description = "Moderator note saved in the report audit trail. Maximum 1000 characters.", example = "Removed duplicate inscription image after review.", maxLength = 1000) @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 index b4fa165..11d31fb 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java @@ -28,6 +28,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -42,42 +44,51 @@ @CompoundIndex(name = "report_target_idx", def = "{'targetId': 1, 'targetType': 1}"), @CompoundIndex(name = "reporter_target_idx", def = "{'reporterId': 1, 'targetId': 1, 'targetType': 1}") }) +@Schema(name = "ModerationReport", description = "Moderation report state, target metadata, AI score, and audit history.") public class ModerationReport { @Id @JsonProperty("_id") @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "Report identifier.", example = "665f1df013ad4e18f6a11250") private ObjectId id; @Field("reporterId") @JsonProperty("reporterId") @Indexed + @Schema(description = "Reporter user id.", example = "665f1df013ad4e18f6a11240") private String reporterId; @Field("targetId") @JsonProperty("targetId") @Indexed + @Schema(description = "Reported target id.", example = "665f1df013ad4e18f6a11244") private String targetId; @Field("targetType") @JsonProperty("targetType") + @Schema(description = "Reported target type.", example = "POST") private ReportTargetType targetType; @Field("targetAuthorId") @JsonProperty("targetAuthorId") + @Schema(description = "Author id of the reported target.", example = "665f1df013ad4e18f6a11241") private String targetAuthorId; @Field("reason") @JsonProperty("reason") + @Schema(description = "Reporter-selected reason.", example = "MISINFORMATION") private ReportReason reason; @Field("details") @JsonProperty("details") + @Schema(description = "Reporter-provided details.", example = "The inscription description contains misleading attribution.") private String details; @Field("status") @JsonProperty("status") @Indexed + @Schema(description = "Current moderation workflow status.", example = "ESCALATED") private ReportStatus status; @Field("activeReportKey") @@ -88,15 +99,18 @@ public class ModerationReport { @Field("actionTaken") @JsonProperty("actionTaken") @Builder.Default + @Schema(description = "Action applied during moderation.", example = "REMOVE_CONTENT") private ModerationAction actionTaken = ModerationAction.NONE; @Field("resolvedBy") @JsonProperty("resolvedBy") + @Schema(description = "Actor that resolved the report.", example = "moderator@example.com") private String resolvedBy; @Field("aiConfidenceScore") @JsonProperty("aiConfidenceScore") @Builder.Default + @Schema(description = "AI confidence score from 0 to 1.", example = "0.87") private Double aiConfidenceScore = 0.0; @CreatedDate @@ -121,6 +135,7 @@ public class ModerationReport { @Field("auditEntries") @JsonProperty("auditEntries") @Builder.Default + @ArraySchema(schema = @Schema(implementation = ReportAuditEntry.class)) private List auditEntries = new ArrayList<>(); public static String buildActiveReportKey(String reporterId, String targetId, ReportTargetType targetType) { 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 index 679b197..6829bf8 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,17 +16,21 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ReportAuditEntry", description = "Single audit trail entry recorded during report moderation.") public class ReportAuditEntry { @Field("actor") @JsonProperty("actor") + @Schema(description = "Actor email or system actor name.", example = "moderator@example.com") private String actor; @Field("message") @JsonProperty("message") + @Schema(description = "Audit message.", example = "Status -> RESOLVED | Action -> REMOVE_CONTENT | By -> moderator@example.com") private String message; @Field("createdAt") @JsonProperty("createdAt") + @Schema(description = "Audit creation timestamp.", example = "2026-05-19T10:35:00.000+00:00") private Date createdAt; } diff --git a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java index 92d9abb..a7be450 100644 --- a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java +++ b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java @@ -16,14 +16,25 @@ import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.user.dto.UpdateProfileRequest; +import com.cadac.stone_inscription.user.dto.UserProfileResponse; import com.cadac.stone_inscription.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @RestController @RequestMapping("/user") +@Tag(name = "Users", description = "Authenticated user profile and image management APIs.") public class UserController { @Autowired @@ -38,6 +49,16 @@ public class UserController { */ @GetMapping("/profile") @Secured("user") + @Operation( + summary = "Get my profile", + description = "Returns the profile attached to the JWT subject.", + responses = { + @ApiResponse(responseCode = "200", description = "Profile fetched successfully", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Profile fetched successfully\",\"http-status\":\"OK\",\"data\":{\"id\":\"665f1df013ad4e18f6a11244\",\"name\":\"Asha Rao\",\"username\":\"asha_rao\",\"email\":\"asha@example.com\",\"profileImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245\",\"coverImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246\",\"bio\":\"Epigraphy researcher\",\"imagesUploaded\":12,\"upvotesReceived\":34,\"followers\":8,\"points\":240}}"))), + @ApiResponse(responseCode = "401", description = "Token is missing or user cannot be resolved", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getProfile(HttpServletRequest request) { String email = extractEmailFromToken(request); return userService.getProfile(email); @@ -49,6 +70,15 @@ public ResponseEntity getProfile(HttpServletRequest request) { */ @PostMapping("/updateProfile") @Secured("user") + @Operation( + summary = "Update my profile", + description = "Updates the authenticated user's editable profile fields. Bean validation constraints are visible in the request schema.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = UpdateProfileRequest.class), + examples = @ExampleObject(value = "{\"username\":\"inscription_scholar\",\"bio\":\"Epigraphy researcher\"}"))), + responses = @ApiResponse(responseCode = "200", description = "Profile updated successfully", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class)))) public ResponseEntity updateProfile( HttpServletRequest request, @Valid @RequestBody UpdateProfileRequest updateProfileRequest) { @@ -64,8 +94,14 @@ public ResponseEntity updateProfile( */ @PostMapping("/uploadProfileImage") @Secured("user") + @Operation( + summary = "Upload profile image", + description = "Replaces the authenticated user's profile image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Profile image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity uploadProfileImage( HttpServletRequest request, + @Parameter(description = "Profile image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -78,8 +114,14 @@ public ResponseEntity uploadProfileImage( */ @PostMapping("/uploadCoverImage") @Secured("user") + @Operation( + summary = "Upload cover image", + description = "Replaces the authenticated user's cover image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Cover image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity uploadCoverImage( HttpServletRequest request, + @Parameter(description = "Cover image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -91,6 +133,15 @@ public ResponseEntity uploadCoverImage( * GET /api/v1/user/public/images/{id} */ @GetMapping("/public/images/{id}") + @Operation( + summary = "Download user image", + description = "Public endpoint that streams a profile or cover image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getUserImage(@PathVariable String id) { return userService.getUserImage(id); } diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java index a26e50f..d7fe911 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.*; @@ -10,13 +11,16 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UpdateProfileRequest", description = "Editable profile fields for the authenticated user.") public class UpdateProfileRequest { @JsonProperty("username") + @Schema(description = "Unique username. Submit only when changing it.", example = "inscription_scholar", minLength = 3, maxLength = 25) @Size(min = 3, max = 25, message = "Username must be between 3 and 25 characters") private String username; @JsonProperty("bio") + @Schema(description = "Short profile bio. Letters, numbers, and spaces only.", example = "Epigraphy researcher", minLength = 3, maxLength = 150, pattern = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$") @Size(min = 3, max = 150, message = "Bio must be between 3 and 150 characters") @Pattern(regexp = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$", message = "Bio can only contain letters, numbers, and spaces") private String bio; diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java index 74e8c0d..d95fd10 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java @@ -2,44 +2,57 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UserProfile", description = "Profile details returned for the authenticated user.") public class UserProfileResponse { @JsonProperty("id") + @Schema(description = "User identifier.", example = "665f1df013ad4e18f6a11244") private String id; @JsonProperty("name") + @Schema(description = "OAuth provider display name.", example = "Asha Rao") private String name; @JsonProperty("username") + @Schema(description = "Application username.", example = "asha_rao") private String username; @JsonProperty("email") + @Schema(description = "User email address.", example = "asha@example.com", format = "email") private String email; @JsonProperty("profileImage") + @Schema(description = "Public profile image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245") private String profileImage; @JsonProperty("coverImage") + @Schema(description = "Public cover image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246") private String coverImage; @JsonProperty("bio") + @Schema(description = "Short user bio.", example = "Epigraphy researcher") private String bio; @JsonProperty("imagesUploaded") + @Schema(description = "Number of uploaded inscription images.", example = "12") private Integer imagesUploaded; @JsonProperty("upvotesReceived") + @Schema(description = "Total upvotes received on user comments/descriptions.", example = "34") private Integer upvotesReceived; @JsonProperty("followers") + @Schema(description = "Follower count.", example = "8") private Integer followers; @JsonProperty("points") + @Schema(description = "Reputation points.", example = "240") private Integer points; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7fed212..b98346f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -63,6 +63,16 @@ logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG spring.servlet.multipart.max-file-size=75MB spring.servlet.multipart.max-request-size=1200MB +# OpenAPI / Swagger UI +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.display-request-duration=true +springdoc.swagger-ui.doc-expansion=none +springdoc.swagger-ui.filter=true +springdoc.swagger-ui.operations-sorter=alpha +springdoc.swagger-ui.tags-sorter=alpha +springdoc.writer-with-order-by-keys=true + management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.prometheus.enabled=true management.metrics.export.prometheus.enabled=true From 14277902f5a5e9ac973dca6a5cba8a0385660ec0 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Thu, 21 May 2026 09:56:35 +0530 Subject: [PATCH 12/28] My test commit Bavith --- .env | 4 +- Artifact-Registory-Backend | 1 + rough.tsx | 168 ++++++++++++++++++ rough2.tsx | 121 +++++++++++++ .../auth/CustomOAuth2SuccessHandler.java | 44 ++--- src/main/resources/application.yml | 2 +- 6 files changed, 315 insertions(+), 25 deletions(-) create mode 160000 Artifact-Registory-Backend create mode 100644 rough.tsx create mode 100644 rough2.tsx diff --git a/.env b/.env index e7760ce..a00b0ee 100644 --- a/.env +++ b/.env @@ -5,7 +5,6 @@ GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret REDIRECT_URL=http://localhost/api/login/oauth2/code -GOOGLE_REDIRECT_URI=http://localhost:8080/login/oauth2/code/{registrationId} # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here FACEBOOK_CLIENT_SECRET=your-facebook-app-secret-here @@ -24,10 +23,11 @@ APPLE_CLIENT_SECRET=your-apple-client-secret-jwt-here CONTENT_MODERATION_WEBHOOK_URL=https://inscriptions.cdacb.in/n8n/webhook/content-moderation CONTENT_MODERATION_INSECURE_SSL=true - CONTENT_MODERATION_SAFE_THRESHOLD=0.7 CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 + + ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com APP_CORS_URL=http://localhost:3000 APP_BACKEND_URL=http://localhost:8080 diff --git a/Artifact-Registory-Backend b/Artifact-Registory-Backend new file mode 160000 index 0000000..3fd00fb --- /dev/null +++ b/Artifact-Registory-Backend @@ -0,0 +1 @@ +Subproject commit 3fd00fb44e75c245bef2db678451fdf6a4ef4105 diff --git a/rough.tsx b/rough.tsx new file mode 100644 index 0000000..59370cc --- /dev/null +++ b/rough.tsx @@ -0,0 +1,168 @@ +import React from "react"; +import { useLocation } from "react-router-dom"; +import { setPostLoginRedirect } from "@/utils/postLoginRedirect"; +// import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google"; +// import { jwtDecode } from "jwt-decode"; +// import { redirect } from "react-router-dom"; + +const redirectURL = window._env_?.VITE_REDIRECT_URL || import.meta.env.VITE_REDIRECT_URL; +const adminLoginRedirectURL = window._env_?.VITE_ADMIN_LOGIN_REDIRECT_URL + || import.meta.env.VITE_ADMIN_LOGIN_REDIRECT_URL + || "/api/oauth2/admin/login"; +const adminRegisterRedirectURL = window._env_?.VITE_ADMIN_REGISTER_REDIRECT_URL + || import.meta.env.VITE_ADMIN_REGISTER_REDIRECT_URL + || "/api/oauth2/admin/register"; +const OAUTH_CALLBACK_GUARD_KEY = "auth:oauth-callback-processed"; + +const AuthPage: React.FC = () => { + const location = useLocation(); + + const getSafeRedirectPath = () => { + const next = new URLSearchParams(location.search).get("next") || ""; + if (next.startsWith("/") && !next.startsWith("//")) { + return next; + } + + const from = location.state && typeof (location.state as { from?: unknown }).from === "string" + ? (location.state as { from: string }).from + : ""; + + if (from.startsWith("/") && !from.startsWith("//")) { + return from; + } + + return null; + }; + + // const handleLoginSuccess = (credentialResponse: any) => { + // if (credentialResponse.credential) { + // const decoded: any = jwtDecode(credentialResponse.credential); + // console.log("User Info:", decoded); + // // You can send credentialResponse.credential to your backend for verification + // } + // }; + + // const handleLoginFailure = () => { + // console.error("Login Failed"); + // }; + const prepareOAuthRedirect = () => { + const redirectPath = getSafeRedirectPath(); + + if (redirectPath) { + setPostLoginRedirect(redirectPath); + } + + // Reset callback guard before initiating a new OAuth round-trip. + sessionStorage.removeItem(OAUTH_CALLBACK_GUARD_KEY); + }; + + const handleGoogleLogin = () => { + prepareOAuthRedirect(); + window.location.href = redirectURL; + } + + const handleAdminLogin = () => { + prepareOAuthRedirect(); + window.location.href = adminLoginRedirectURL; + }; + + const handleAdminRegister = () => { + prepareOAuthRedirect(); + window.location.href = adminRegisterRedirectURL; + }; + + return ( +
+
+

+ Welcome Back 👋 +

+

+ Sign in or create an account with Google +

+ {/* + + */} + + + +
+ + + +
+ +
+ By continuing, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + +
+
+
+ ); +}; + +export default AuthPage; diff --git a/rough2.tsx b/rough2.tsx new file mode 100644 index 0000000..48dcb11 --- /dev/null +++ b/rough2.tsx @@ -0,0 +1,121 @@ +import { useContext, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authClient } from "@/utils/http/clients/authClient.client"; +import AuthContext from "@/context/AuthContext"; +import cdacRoundLogo from '@/assets/cdacroundlogo.png'; +import { getPostLoginRedirect } from "@/utils/postLoginRedirect"; + +const MAX_REFRESH_RETRIES = 3; +const RETRY_DELAY_MS = 700; +const OAUTH_CALLBACK_GUARD_KEY = "auth:oauth-callback-processed"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const OAuthCallback = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { loginSuccess } = useContext(AuthContext); + + useEffect(() => { + // React StrictMode runs effects twice in development. + // Guard prevents a second callback pass from overriding the first successful redirect. + if (sessionStorage.getItem(OAUTH_CALLBACK_GUARD_KEY) === "1") { + return; + } + sessionStorage.setItem(OAUTH_CALLBACK_GUARD_KEY, "1"); + + const navigateToLoginWithNext = () => { + sessionStorage.removeItem(OAUTH_CALLBACK_GUARD_KEY); + const next = getPostLoginRedirect(); + if (next) { + navigate(`/login?next=${encodeURIComponent(next)}`, { replace: true }); + } else { + navigate("/login", { replace: true }); + } + }; + + const completeLogin = async () => { + try { + const status = searchParams.get("status"); + const flow = searchParams.get("flow"); + + if (status === "pending" && flow === "admin_register") { + navigate("/login?admin_request=pending", { replace: true }); + return; + } + + if (status === "denied" && flow === "admin_login") { + navigate("/login?admin_access=denied", { replace: true }); + return; + } + + if (status && status !== "success") { + throw new Error(`OAuth callback returned unsupported status: ${status}`); + } + + let accessToken: string | null = null; + let lastError: unknown = null; + + for (let attempt = 1; attempt <= MAX_REFRESH_RETRIES; attempt++) { + try { + console.log(`OAuthCallback: refresh attempt ${attempt}/${MAX_REFRESH_RETRIES}`); + const res = await authClient.post("/oauth2/authenticated/refresh-token"); + console.log("OAuthCallback: refresh response:", res && res.data); + + accessToken = res?.data?.data?.accessToken || res?.data?.auth_token || res?.data?.token || null; + console.log("OAuthCallback: computed accessToken:", accessToken); + + if (accessToken) break; + lastError = new Error("No access token found in refresh response"); + } catch (error) { + lastError = error; + console.warn(`OAuthCallback: refresh attempt ${attempt} failed`, { + message: (error as any)?.message, + status: (error as any)?.response?.status, + }); + } + + if (attempt < MAX_REFRESH_RETRIES) { + await wait(RETRY_DELAY_MS * attempt); + } + } + + if (accessToken) { + loginSuccess(accessToken); + + const savedRedirect = getPostLoginRedirect(); + + if (savedRedirect && savedRedirect.startsWith("/") && !savedRedirect.startsWith("//")) { + navigate(savedRedirect, { replace: true }); + } else { + navigate("/home", { replace: true }); + } + } else { + throw lastError || new Error("OAuth callback failed without an access token"); + } + } catch (error) { + console.error("Error completing OAuth login:", { + message: (error as any)?.message, + response: (error as any)?.response?.data, + status: (error as any)?.response?.status, + }); + navigateToLoginWithNext(); + } + }; + + completeLogin(); + }, [loginSuccess, navigate, searchParams]); + + return ( +
+
+ {/* */} + +
Logging in...
+
+
+ ) + +}; + +export default OAuthCallback; diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index bd27e36..6903aa0 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -1,11 +1,11 @@ -package com.cadac.stone_inscription.auth; - +package com.cadac.stone_inscription.auth; + import java.io.IOException; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; - + import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; @@ -25,14 +25,14 @@ import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.repository.UserAuthRepository; import com.cadac.stone_inscription.repository.UserRepository; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final UserRepository userRepository; @@ -48,16 +48,16 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, - Authentication authentication - ) throws IOException, ServletException { - - OAuth2AuthenticationToken oauthToken = - (OAuth2AuthenticationToken) authentication; - - Map attributes = - oauthToken.getPrincipal().getAttributes(); - - String provider = oauthToken.getAuthorizedClientRegistrationId(); + Authentication authentication + ) throws IOException, ServletException { + + OAuth2AuthenticationToken oauthToken = + (OAuth2AuthenticationToken) authentication; + + Map attributes = + oauthToken.getPrincipal().getAttributes(); + + String provider = oauthToken.getAuthorizedClientRegistrationId(); String email = (String) attributes.get("email"); String name = (String) attributes.get("name"); String picture = (String) attributes.get("picture"); @@ -65,7 +65,7 @@ public void onAuthenticationSuccess( UserAuth userAuth = findOrCreateUser(email, name, picture, provider); oAuthFlowCookieService.clearFlow(response); - + System.out.println(flowType); switch (flowType) { case ADMIN_REGISTER -> { adminAccessService.createOrRefreshPendingRequest(userAuth, name, provider); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 77d2ec8..5f1dc0b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,7 +65,7 @@ spring: - openid - profile - email - redirect-uri: ${GOOGLE_REDIRECT_URI:https://inscriptions.cdacb.in/api/login/oauth2/code/{registrationId}} + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # Facebook OAuth2 Configuration facebook: client-id: ${FACEBOOK_CLIENT_ID} From dc24ebd04e964b7e12b57349837087fbeb018046 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Thu, 21 May 2026 10:55:34 +0530 Subject: [PATCH 13/28] working admin branch --- .env | 14 +++--- rough.tsx | 4 +- .../auth/CustomOAuth2SuccessHandler.java | 46 +++++++++++-------- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/.env b/.env index a00b0ee..0114136 100644 --- a/.env +++ b/.env @@ -28,17 +28,17 @@ CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 -ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com -APP_CORS_URL=http://localhost:3000 -APP_BACKEND_URL=http://localhost:8080 -APP_FRONTEND_OAUTH_CALLBACK_URL=http://localhost:8080/api/v1/noauth/check -APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/api/v1/noauth/check +ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com +APP_CORS_URL=https://inscriptions.cdacb.in +APP_BACKEND_URL=https://inscriptions.cdacb.in/api +APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback +APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result # Spring Mail spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username=your-email@example.com -spring.mail.password=your-app-password +spring.mail.username=bavithbabu25@gmail.com +spring.mail.password=ugfj wcfo cbah hfau spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true diff --git a/rough.tsx b/rough.tsx index 59370cc..bf9abf3 100644 --- a/rough.tsx +++ b/rough.tsx @@ -5,7 +5,9 @@ import { setPostLoginRedirect } from "@/utils/postLoginRedirect"; // import { jwtDecode } from "jwt-decode"; // import { redirect } from "react-router-dom"; -const redirectURL = window._env_?.VITE_REDIRECT_URL || import.meta.env.VITE_REDIRECT_URL; +const redirectURL = window._env_?.VITE_REDIRECT_URL + || import.meta.env.VITE_REDIRECT_URL + || "/api/oauth2/login"; const adminLoginRedirectURL = window._env_?.VITE_ADMIN_LOGIN_REDIRECT_URL || import.meta.env.VITE_ADMIN_LOGIN_REDIRECT_URL || "/api/oauth2/admin/login"; diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 6903aa0..617b022 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -9,7 +9,6 @@ import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -22,7 +21,6 @@ import com.cadac.stone_inscription.auth.utill.GenrateRefreshToken; 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.repository.UserAuthRepository; import com.cadac.stone_inscription.repository.UserRepository; @@ -65,26 +63,34 @@ public void onAuthenticationSuccess( UserAuth userAuth = findOrCreateUser(email, name, picture, provider); oAuthFlowCookieService.clearFlow(response); - System.out.println(flowType); - switch (flowType) { - case ADMIN_REGISTER -> { - adminAccessService.createOrRefreshPendingRequest(userAuth, name, provider); - redirect(request, response, "pending", "admin_register"); - } - case ADMIN_LOGIN -> { - if (!adminAccessService.isApprovedAdmin(email)) { - redirect(request, response, "denied", "admin_login"); - return; - } - issueRefreshCookie(response, userAuth.getId(), "admin"); - redirect(request, response, "success", "admin_login"); - } - case USER_LOGIN -> { - issueRefreshCookie(response, userAuth.getId(), "user"); - redirect(request, response, "success", "user_login"); + if (flowType == OAuthFlowType.ADMIN_REGISTER) { + adminAccessService + .createOrRefreshPendingRequest( + userAuth, name, provider); + redirect(request, response, + "pending", "admin_register"); + return; + } + + if (flowType == OAuthFlowType.ADMIN_LOGIN) { + if (!adminAccessService + .isApprovedAdmin(email)) { + redirect(request, response, + "denied", "admin_login"); + return; } - default -> throw new StoneInscriptionException("Unsupported OAuth flow", HttpStatus.BAD_REQUEST); + issueRefreshCookie(response, + userAuth.getId(), "admin"); + redirect(request, response, + "success", "admin_login"); + return; } + + issueRefreshCookie(response, + userAuth.getId(), "user"); + getRedirectStrategy().sendRedirect( + request, response, + frontendCallbackUrl + "?status=success"); } private UserAuth findOrCreateUser(String email, String name, String picture, String provider) { From faf56420c6b8f9ae0c4b09f87433804f12c974d7 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Thu, 21 May 2026 11:58:14 +0530 Subject: [PATCH 14/28] working admin backend time 11:58 --- .env | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.env b/.env index 0114136..01d2e34 100644 --- a/.env +++ b/.env @@ -2,8 +2,11 @@ # Replace with your actual OAuth provider credentials # Google OAuth2 -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret +# GOOGLE_CLIENT_ID=your-google-client-id +# GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_CLIENT_ID=760575932383-1ah7nvt9vi02pr5r20h5nm5g8p1pl87j.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + REDIRECT_URL=http://localhost/api/login/oauth2/code # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here From 32a4569bc19886a331b6536024bc250cf2473155 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Thu, 21 May 2026 15:16:50 +0530 Subject: [PATCH 15/28] Pushing for testing in production --- .env | 10 ++++------ .../admin/service/AdminAccessServiceImpl.java | 2 +- .../auth/CustomOAuth2SuccessHandler.java | 2 +- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.env b/.env index 01d2e34..f82c57c 100644 --- a/.env +++ b/.env @@ -2,11 +2,8 @@ # Replace with your actual OAuth provider credentials # Google OAuth2 -# GOOGLE_CLIENT_ID=your-google-client-id -# GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CLIENT_ID=760575932383-1ah7nvt9vi02pr5r20h5nm5g8p1pl87j.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=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 - +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret REDIRECT_URL=http://localhost/api/login/oauth2/code # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here @@ -33,10 +30,11 @@ CONTENT_MODERATION_READ_TIMEOUT_MS=10000 ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com APP_CORS_URL=https://inscriptions.cdacb.in -APP_BACKEND_URL=https://inscriptions.cdacb.in/api APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback +APP_BACKEND_URL=https://inscriptions.cdacb.in/api APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result + # Spring Mail spring.mail.host=smtp.gmail.com spring.mail.port=587 diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java index 1d56e64..9aba208 100644 --- a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java @@ -33,7 +33,7 @@ public class AdminAccessServiceImpl implements AdminAccessService { @Value("${app.backend.url}") private String backendUrl; - @Value("${app.frontend.admin.approval-result-url:https://inscriptions.cdacb.in/admin/approval-result}") + @Value("${app.frontend.admin.approval-result-url}") private String approvalResultUrl; @Value("${admin.approval.token-validity-ms:86400000}") diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 617b022..8d7d134 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -39,7 +39,7 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa private final AdminAccessService adminAccessService; private final OAuthFlowCookieService oAuthFlowCookieService; - @Value("${app.frontend.oauth.callback-url:https://inscriptions.cdacb.in/oauth/callback}") + @Value("${app.frontend.oauth.callback-url}") private String frontendCallbackUrl; @Override From ada35642995f208f9f77a5478eb68cd6de83663d Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Fri, 22 May 2026 10:45:47 +0530 Subject: [PATCH 16/28] Done with the admin need testing --- .env | 10 ++++++++-- Artifact-Registory-Backend | 1 - .../auth/CustomOAuth2SuccessHandler.java | 16 ++++++++++++++++ .../stone_inscription/auth/OAuthFlowType.java | 3 ++- .../auth/controller/OAuthController.java | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 4 deletions(-) delete mode 160000 Artifact-Registory-Backend diff --git a/.env b/.env index f82c57c..ebef5e5 100644 --- a/.env +++ b/.env @@ -2,8 +2,11 @@ # Replace with your actual OAuth provider credentials # Google OAuth2 -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret +# GOOGLE_CLIENT_ID=your-google-client-id +# GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_CLIENT_ID=760575932383-1ah7nvt9vi02pr5r20h5nm5g8p1pl87j.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + REDIRECT_URL=http://localhost/api/login/oauth2/code # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here @@ -31,6 +34,9 @@ CONTENT_MODERATION_READ_TIMEOUT_MS=10000 ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com APP_CORS_URL=https://inscriptions.cdacb.in APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback +# APP_BACKEND_URL=http://localhost:8080 +# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/api/oauth2/admin/approve + APP_BACKEND_URL=https://inscriptions.cdacb.in/api APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result diff --git a/Artifact-Registory-Backend b/Artifact-Registory-Backend deleted file mode 160000 index 3fd00fb..0000000 --- a/Artifact-Registory-Backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3fd00fb44e75c245bef2db678451fdf6a4ef4105 diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 8d7d134..f0d8295 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -86,6 +86,22 @@ public void onAuthenticationSuccess( return; } + if (flowType == OAuthFlowType.ADMIN_AUTH) { + if (adminAccessService.isApprovedAdmin(email)) { + issueRefreshCookie(response, + userAuth.getId(), "admin"); + redirect(request, response, + "success", "admin_login"); + return; + } + adminAccessService + .createOrRefreshPendingRequest( + userAuth, name, provider); + redirect(request, response, + "pending", "admin_register"); + return; + } + issueRefreshCookie(response, userAuth.getId(), "user"); getRedirectStrategy().sendRedirect( diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java index e3fb526..8df5c35 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java @@ -3,7 +3,8 @@ public enum OAuthFlowType { USER_LOGIN("user_login"), ADMIN_REGISTER("admin_register"), - ADMIN_LOGIN("admin_login"); + ADMIN_LOGIN("admin_login"), + ADMIN_AUTH("admin_auth"); private final String value; diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index 2d2fc05..f8776b9 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -130,6 +130,7 @@ public void login(HttpServletResponse response) throws IOException { loginWithProvider(defaultProvider, response); } + // Deprecated: use /admin/authorization/{provider} instead. @GetMapping("/admin/register/{provider}") @Operation(summary = "Start admin registration", description = "Starts OAuth flow for an admin registration request.") public void adminRegister( @@ -140,12 +141,14 @@ public void adminRegister( response.sendRedirect("/oauth2/authorization/" + provider); } + // Deprecated: use /admin/authorization instead. @GetMapping("/admin/register") @Operation(summary = "Start default admin registration", description = "Starts admin registration using the configured default OAuth provider.") public void adminRegisterDefault(HttpServletResponse response) throws IOException { adminRegister(defaultProvider, response); } + // Deprecated: use /admin/authorization/{provider} instead. @GetMapping("/admin/login/{provider}") @Operation(summary = "Start admin OAuth login", description = "Starts OAuth login for an approved admin account.") public void adminLogin( @@ -156,12 +159,28 @@ public void adminLogin( response.sendRedirect("/oauth2/authorization/" + provider); } + // Deprecated: use /admin/authorization instead. @GetMapping("/admin/login") @Operation(summary = "Start default admin OAuth login", description = "Starts admin login using the configured default OAuth provider.") public void adminLoginDefault(HttpServletResponse response) throws IOException { adminLogin(defaultProvider, response); } + @GetMapping("/admin/authorization/{provider}") + public void adminAuth( + @PathVariable String provider, + HttpServletResponse response) throws IOException { + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); + response.sendRedirect("/oauth2/authorization/" + provider); + } + + @GetMapping("/admin/authorization") + public void adminAuthDefault( + HttpServletResponse response) throws IOException { + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); + response.sendRedirect("/oauth2/authorization/" + defaultProvider); + } + @GetMapping("/admin/approve") @Operation( summary = "Approve admin request", From 19625a8c8a9f7ecec77e77cf05616f532d24d49d Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Fri, 22 May 2026 12:20:46 +0530 Subject: [PATCH 17/28] Changed the redirecting url's --- .env | 5 ++-- .../auth/CustomOAuth2SuccessHandler.java | 24 +++++++++++++++---- src/main/resources/application.properties | 1 + 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.env b/.env index ebef5e5..95cf4e2 100644 --- a/.env +++ b/.env @@ -34,12 +34,13 @@ CONTENT_MODERATION_READ_TIMEOUT_MS=10000 ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com APP_CORS_URL=https://inscriptions.cdacb.in APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback -# APP_BACKEND_URL=http://localhost:8080 -# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/api/oauth2/admin/approve +APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL=https://inscriptions.cdacb.in/admin/oauth/callback APP_BACKEND_URL=https://inscriptions.cdacb.in/api APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result +# APP_BACKEND_URL=http://localhost:8080 +# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/oauth2/admin/approve # Spring Mail spring.mail.host=smtp.gmail.com diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index f0d8295..c0a7ff7 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -42,6 +42,9 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa @Value("${app.frontend.oauth.callback-url}") private String frontendCallbackUrl; + @Value("${app.frontend.oauth.admin-callback-url}") + private String frontendAdminCallbackUrl; + @Override public void onAuthenticationSuccess( HttpServletRequest request, @@ -67,7 +70,7 @@ public void onAuthenticationSuccess( adminAccessService .createOrRefreshPendingRequest( userAuth, name, provider); - redirect(request, response, + redirectAdmin(request, response, "pending", "admin_register"); return; } @@ -75,13 +78,13 @@ public void onAuthenticationSuccess( if (flowType == OAuthFlowType.ADMIN_LOGIN) { if (!adminAccessService .isApprovedAdmin(email)) { - redirect(request, response, + redirectAdmin(request, response, "denied", "admin_login"); return; } issueRefreshCookie(response, userAuth.getId(), "admin"); - redirect(request, response, + redirectAdmin(request, response, "success", "admin_login"); return; } @@ -90,14 +93,14 @@ public void onAuthenticationSuccess( if (adminAccessService.isApprovedAdmin(email)) { issueRefreshCookie(response, userAuth.getId(), "admin"); - redirect(request, response, + redirectAdmin(request, response, "success", "admin_login"); return; } adminAccessService .createOrRefreshPendingRequest( userAuth, name, provider); - redirect(request, response, + redirectAdmin(request, response, "pending", "admin_register"); return; } @@ -175,4 +178,15 @@ private void redirect( response, frontendCallbackUrl + "?status=" + status + "&flow=" + flow); } + + private void redirectAdmin( + HttpServletRequest request, + HttpServletResponse response, + String status, + String flow) throws IOException { + getRedirectStrategy().sendRedirect( + request, + response, + frontendAdminCallbackUrl + "?status=" + status + "&flow=" + flow); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b98346f..467d8d5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,6 +16,7 @@ app.cors.url=${APP_CORS_URL:https://inscriptions.cdacb.in} app.backend.url=${APP_BACKEND_URL:https://inscriptions.cdacb.in/api} app.frontend.oauth.callback-url=${APP_FRONTEND_OAUTH_CALLBACK_URL:https://inscriptions.cdacb.in/oauth/callback} +app.frontend.oauth.admin-callback-url=${APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL} app.frontend.admin.approval-result-url=${APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL:https://inscriptions.cdacb.in/admin/approval-result} admin.approval.token-validity-ms=${ADMIN_APPROVAL_TOKEN_VALIDITY_MS:86400000} From edbe837a07ed1a6098ce4e220da1a7e8a5ae6208 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Mon, 25 May 2026 11:58:17 +0530 Subject: [PATCH 18/28] updated the swager api --- .../configuration/OpenApiConfiguration.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java index de35f49..87484fc 100644 --- a/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java +++ b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java @@ -7,12 +7,11 @@ import org.springframework.security.access.annotation.Secured; import org.springframework.web.method.HandlerMethod; -import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; -import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; @@ -28,8 +27,9 @@ name = OpenApiConfiguration.BEARER_AUTH, type = SecuritySchemeType.HTTP, scheme = "bearer", - bearerFormat = "JWT", - in = SecuritySchemeIn.HEADER) + bearerFormat = "JWT" + // in = SecuritySchemeIn.HEADER + ) public class OpenApiConfiguration { public static final String BEARER_AUTH = "bearerAuth"; From 9a18eec7083cf93bc4e44a7557fe1192e6ed593f Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Mon, 25 May 2026 14:05:00 +0530 Subject: [PATCH 19/28] New code bro --- .../auth/CustomOAuth2SuccessHandler.java | 23 ------------ .../stone_inscription/auth/OAuthFlowType.java | 2 -- .../auth/controller/OAuthController.java | 36 ------------------- 3 files changed, 61 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index c0a7ff7..4f720d7 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -66,29 +66,6 @@ public void onAuthenticationSuccess( UserAuth userAuth = findOrCreateUser(email, name, picture, provider); oAuthFlowCookieService.clearFlow(response); - if (flowType == OAuthFlowType.ADMIN_REGISTER) { - adminAccessService - .createOrRefreshPendingRequest( - userAuth, name, provider); - redirectAdmin(request, response, - "pending", "admin_register"); - return; - } - - if (flowType == OAuthFlowType.ADMIN_LOGIN) { - if (!adminAccessService - .isApprovedAdmin(email)) { - redirectAdmin(request, response, - "denied", "admin_login"); - return; - } - issueRefreshCookie(response, - userAuth.getId(), "admin"); - redirectAdmin(request, response, - "success", "admin_login"); - return; - } - if (flowType == OAuthFlowType.ADMIN_AUTH) { if (adminAccessService.isApprovedAdmin(email)) { issueRefreshCookie(response, diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java index 8df5c35..bd4d070 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java @@ -2,8 +2,6 @@ public enum OAuthFlowType { USER_LOGIN("user_login"), - ADMIN_REGISTER("admin_register"), - ADMIN_LOGIN("admin_login"), ADMIN_AUTH("admin_auth"); private final String value; diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index f8776b9..199ef88 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -130,42 +130,6 @@ public void login(HttpServletResponse response) throws IOException { loginWithProvider(defaultProvider, response); } - // Deprecated: use /admin/authorization/{provider} instead. - @GetMapping("/admin/register/{provider}") - @Operation(summary = "Start admin registration", description = "Starts OAuth flow for an admin registration request.") - public void adminRegister( - @Parameter(description = "OAuth provider registration id.", example = "google") - @PathVariable String provider, - HttpServletResponse response) throws IOException { - oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_REGISTER); - response.sendRedirect("/oauth2/authorization/" + provider); - } - - // Deprecated: use /admin/authorization instead. - @GetMapping("/admin/register") - @Operation(summary = "Start default admin registration", description = "Starts admin registration using the configured default OAuth provider.") - public void adminRegisterDefault(HttpServletResponse response) throws IOException { - adminRegister(defaultProvider, response); - } - - // Deprecated: use /admin/authorization/{provider} instead. - @GetMapping("/admin/login/{provider}") - @Operation(summary = "Start admin OAuth login", description = "Starts OAuth login for an approved admin account.") - public void adminLogin( - @Parameter(description = "OAuth provider registration id.", example = "google") - @PathVariable String provider, - HttpServletResponse response) throws IOException { - oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_LOGIN); - response.sendRedirect("/oauth2/authorization/" + provider); - } - - // Deprecated: use /admin/authorization instead. - @GetMapping("/admin/login") - @Operation(summary = "Start default admin OAuth login", description = "Starts admin login using the configured default OAuth provider.") - public void adminLoginDefault(HttpServletResponse response) throws IOException { - adminLogin(defaultProvider, response); - } - @GetMapping("/admin/authorization/{provider}") public void adminAuth( @PathVariable String provider, From f0d8afdd01a560a89d9999c4829316687482f0d9 Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Mon, 25 May 2026 14:27:22 +0530 Subject: [PATCH 20/28] Some cookie issue resolved --- .env | 1 + .../cadac/stone_inscription/auth/OAuthFlowCookieService.java | 5 +++++ src/main/resources/application.properties | 1 + 3 files changed, 7 insertions(+) diff --git a/.env b/.env index 95cf4e2..847db4a 100644 --- a/.env +++ b/.env @@ -33,6 +33,7 @@ CONTENT_MODERATION_READ_TIMEOUT_MS=10000 ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com APP_CORS_URL=https://inscriptions.cdacb.in +APP_COOKIE_DOMAIN=inscriptions.cdacb.in APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL=https://inscriptions.cdacb.in/admin/oauth/callback diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java index f41acdf..be5d879 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @@ -16,12 +17,16 @@ public class OAuthFlowCookieService { public static final String FLOW_COOKIE_NAME = "oauth_flow"; + @Value("${app.cookie.domain}") + private String cookieDomain; + public void storeFlow(HttpServletResponse response, OAuthFlowType flowType) { ResponseCookie cookie = ResponseCookie.from(FLOW_COOKIE_NAME, flowType.getValue()) .httpOnly(true) .secure(true) .sameSite("None") .path("/") + .domain(cookieDomain) .maxAge(Duration.ofMinutes(10)) .build(); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 467d8d5..afb70ea 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,6 +13,7 @@ spring.data.mongodb.uri=${MONGO_URI} # CORS configuration app.cors.url=${APP_CORS_URL:https://inscriptions.cdacb.in} +app.cookie.domain=${APP_COOKIE_DOMAIN} app.backend.url=${APP_BACKEND_URL:https://inscriptions.cdacb.in/api} app.frontend.oauth.callback-url=${APP_FRONTEND_OAUTH_CALLBACK_URL:https://inscriptions.cdacb.in/oauth/callback} From 8efaa2080de7e11be04ee7ce6f36205377c51913 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Wed, 27 May 2026 12:15:34 +0530 Subject: [PATCH 21/28] fixed kafka producer consumer call --- src/main/resources/application.yml | 40 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5f1dc0b..f8329e4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,17 +39,17 @@ spring: # percentiles-histogram: # http.server.requests: true kafka: - bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - consumer: - group-id: stone-inscription-group - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer - auto-offset-reset: earliest - properties: - spring.json.trusted.packages: "com.cadac.stone_inscription.kafka.events,com.cadac.stone_inscription.kafka.events.*" + consumer: + group-id: stone-inscription-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + auto-offset-reset: earliest + properties: + spring.json.trusted.packages: "com.cadac.stone_inscription.kafka.events,com.cadac.stone_inscription.kafka.events.*" @@ -58,22 +58,22 @@ spring: client: registration: # Google OAuth2 Configuration - google: - client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - openid - - profile - - email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" - # Facebook OAuth2 Configuration + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: + - openid + - profile + - email + redirect-uri: "{baseUrl}/api/login/oauth2/code/{registrationId}" + # Facebook OAuth2 Configuration facebook: client-id: ${FACEBOOK_CLIENT_ID} client-secret: ${FACEBOOK_CLIENT_SECRET} scope: - email - public_profile - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "{baseUrl}/api/login/oauth2/code/{registrationId}" # Apple OAuth2 Configuration apple: @@ -82,7 +82,7 @@ spring: scope: - name - email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "{baseUrl}/api/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code client-authentication-method: client_secret_post client-name: Apple @@ -135,4 +135,4 @@ management: metrics: export: prometheus: - enabled: true + enabled: true From 2e4d20f3a7d9fc9331142f28b8e9424f66b5f20b Mon Sep 17 00:00:00 2001 From: Bavithbabu Date: Wed, 27 May 2026 12:20:06 +0530 Subject: [PATCH 22/28] resolved merge conflict --- .env | 11 +- .../auth/CustomOAuth2SuccessHandler.java | 10 ++ .../auth/OAuthFlowCookieService.java | 24 +++- .../auth/controller/OAuthController.java | 122 +++++++++--------- .../post/controller/PostController.java | 2 +- 5 files changed, 103 insertions(+), 66 deletions(-) diff --git a/.env b/.env index 847db4a..12e74b5 100644 --- a/.env +++ b/.env @@ -33,15 +33,16 @@ CONTENT_MODERATION_READ_TIMEOUT_MS=10000 ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com APP_CORS_URL=https://inscriptions.cdacb.in -APP_COOKIE_DOMAIN=inscriptions.cdacb.in +# APP_COOKIE_DOMAIN=inscriptions.cdacb.in +APP_COOKIE_DOMAIN=localhost APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL=https://inscriptions.cdacb.in/admin/oauth/callback -APP_BACKEND_URL=https://inscriptions.cdacb.in/api -APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result +# APP_BACKEND_URL=https://inscriptions.cdacb.in/api +# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result -# APP_BACKEND_URL=http://localhost:8080 -# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/oauth2/admin/approve +APP_BACKEND_URL=http://localhost:8080 +APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/oauth2/admin/approve # Spring Mail spring.mail.host=smtp.gmail.com diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index 4f720d7..a902bf1 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -7,6 +7,8 @@ import java.util.Map; import org.bson.types.ObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -33,6 +35,9 @@ @RequiredArgsConstructor public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private static final Logger log = + LoggerFactory.getLogger(CustomOAuth2SuccessHandler.class); + private final UserRepository userRepository; private final UserAuthRepository userAuthRepository; private final RefreshTokenRepo refreshTokenRepo; @@ -63,10 +68,15 @@ public void onAuthenticationSuccess( String name = (String) attributes.get("name"); String picture = (String) attributes.get("picture"); OAuthFlowType flowType = oAuthFlowCookieService.readFlow(request); + log.debug("DEBUG flowType: " + flowType); + log.debug("DEBUG handler: flowType=" + flowType + + " email=" + email); UserAuth userAuth = findOrCreateUser(email, name, picture, provider); oAuthFlowCookieService.clearFlow(response); if (flowType == OAuthFlowType.ADMIN_AUTH) { + log.debug("DEBUG ADMIN_AUTH: isApprovedAdmin=" + + adminAccessService.isApprovedAdmin(email)); if (adminAccessService.isApprovedAdmin(email)) { issueRefreshCookie(response, userAuth.getId(), "admin"); diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java index be5d879..2b56003 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java @@ -3,6 +3,8 @@ import java.time.Duration; import java.util.Arrays; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; @@ -15,6 +17,9 @@ @Component public class OAuthFlowCookieService { + private static final Logger log = + LoggerFactory.getLogger(OAuthFlowCookieService.class); + public static final String FLOW_COOKIE_NAME = "oauth_flow"; @Value("${app.cookie.domain}") @@ -29,22 +34,37 @@ public void storeFlow(HttpServletResponse response, OAuthFlowType flowType) { .domain(cookieDomain) .maxAge(Duration.ofMinutes(10)) .build(); + log.debug("DEBUG storeFlow: storing flow=" + + flowType.getValue() + + " domain=" + cookieDomain + + " cookie=" + cookie.toString()); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } public OAuthFlowType readFlow(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); + log.debug("DEBUG readFlow: cookies present=" + + (cookies != null ? cookies.length : 0)); if (cookies == null) { return OAuthFlowType.USER_LOGIN; } + log.debug("DEBUG readFlow: oauth_flow cookie value=" + + Arrays.stream(cookies == null ? new Cookie[0] : cookies) + .filter(c -> FLOW_COOKIE_NAME.equals(c.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse("NOT FOUND")); - return Arrays.stream(cookies) + OAuthFlowType flowType = Arrays.stream(cookies) .filter(cookie -> FLOW_COOKIE_NAME.equals(cookie.getName())) .map(Cookie::getValue) .findFirst() .map(OAuthFlowType::fromValue) .orElse(OAuthFlowType.USER_LOGIN); + log.debug("DEBUG readFlow: resolved flowType=" + + flowType); + return flowType; } public void clearFlow(HttpServletResponse response) { @@ -53,9 +73,11 @@ public void clearFlow(HttpServletResponse response) { .secure(true) .sameSite("None") .path("/") + .domain(cookieDomain) .maxAge(Duration.ZERO) .build(); + log.debug("DEBUG clearFlow: clearing cookie domain={}", cookieDomain); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); } } diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index 199ef88..33b95de 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -1,14 +1,20 @@ -package com.cadac.stone_inscription.auth.controller; - +package com.cadac.stone_inscription.auth.controller; + import java.io.IOException; import java.util.Arrays; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UsernameNotFoundException; 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.RequestParam; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.admin.service.AdminAccessService; @@ -17,17 +23,9 @@ import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.auth.OAuthFlowCookieService; import com.cadac.stone_inscription.auth.OAuthFlowType; -import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.auth.service.StoneAuthService; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.nimbusds.jose.JOSEException; - -import jakarta.servlet.http.HttpServletResponse; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.userdetails.UsernameNotFoundException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -38,6 +36,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; @RestController @RequestMapping("/oauth2") @@ -45,6 +44,9 @@ public class OAuthController { + private static final Logger log = + LoggerFactory.getLogger(OAuthController.class); + @Autowired StoneAuthService stoneAuthService; @@ -68,8 +70,8 @@ public class OAuthController { public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { return stoneAuthService.logoutAuth(request, response); - } - + } + @PostMapping("/authenticated/refresh-token") @Operation( summary = "Refresh access token", @@ -82,31 +84,31 @@ public ResponseEntity logoutAuth(HttpServletRequest request, HttpServletRespo content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) }) public ResponseEntity refreshToken(HttpServletResponse response, HttpServletRequest request) - throws UsernameNotFoundException, JOSEException { - - - return stoneAuthService.refreshToken(request, response); - } - + throws UsernameNotFoundException, JOSEException { + + + return stoneAuthService.refreshToken(request, response); + } + @PostMapping("/authenticated/active") @Operation( summary = "Mark session active", description = "Refreshes last-use time for the refresh-token session. Returns no content on success.", responses = @ApiResponse(responseCode = "204", description = "Session marked active")) public ResponseEntity updateLastActive(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - throw new StoneInscriptionException("Unauthorized", HttpStatus.UNAUTHORIZED); - } - String refreshToken = Arrays.stream(request.getCookies()) - .filter(cookie -> "refreshToken".equals(cookie.getName())) - .map(Cookie::getValue) - .findFirst() - .orElse(null); - - if (refreshToken == null) { - throw new StoneInscriptionException("Invalid Request No token found", HttpStatus.BAD_REQUEST); - } + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + throw new StoneInscriptionException("Unauthorized", HttpStatus.UNAUTHORIZED); + } + String refreshToken = Arrays.stream(request.getCookies()) + .filter(cookie -> "refreshToken".equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + + if (refreshToken == null) { + throw new StoneInscriptionException("Invalid Request No token found", HttpStatus.BAD_REQUEST); + } return stoneAuthService.updateLastActive(refreshToken); } @@ -134,8 +136,10 @@ public void login(HttpServletResponse response) throws IOException { public void adminAuth( @PathVariable String provider, HttpServletResponse response) throws IOException { + log.info("ADMIN_AUTH: storeFlow called provider={}", provider); oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); response.sendRedirect("/oauth2/authorization/" + provider); + log.info("ADMIN_AUTH: redirected to /oauth2/authorization/{}", provider); } @GetMapping("/admin/authorization") @@ -165,30 +169,30 @@ public void approveAdminRequest( // @Autowired // private JwtUtil jwtUtil; - - // @GetMapping("/google") - // public void loginWithGoogle(HttpServletResponse response) throws IOException - // { - - // response.sendRedirect("/oauth2/authorization/google"); - // } - - // @GetMapping("/csrf-token") - // public CsrfToken csrfToken(HttpServletRequest request) { - // return (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - // } - - // @PostMapping("/refresh-token") - // public ResponseEntity refreshToken(HttpServletRequest request) { - - // try { - // String oldToken = request.getHeader("Authorization").substring(7); - // System.out.println(oldToken); - // String newToken = jwtUtil.refreshToken(oldToken); - // return ResponseEntity.ok(Map.of("token", newToken)); - // } catch (Exception e) { - // return ResponseEntity.status(403).body("Invalid or expired token."); - // } - // } - -} + + // @GetMapping("/google") + // public void loginWithGoogle(HttpServletResponse response) throws IOException + // { + + // response.sendRedirect("/oauth2/authorization/google"); + // } + + // @GetMapping("/csrf-token") + // public CsrfToken csrfToken(HttpServletRequest request) { + // return (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + // } + + // @PostMapping("/refresh-token") + // public ResponseEntity refreshToken(HttpServletRequest request) { + + // try { + // String oldToken = request.getHeader("Authorization").substring(7); + // System.out.println(oldToken); + // String newToken = jwtUtil.refreshToken(oldToken); + // return ResponseEntity.ok(Map.of("token", newToken)); + // } catch (Exception e) { + // return ResponseEntity.status(403).body("Invalid or expired token."); + // } + // } + +} 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 111057b..6538c63 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 @@ -20,10 +20,10 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.api.dto.ApiErrorResponse; import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.api.dto.DashboardCountsResponse; +import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.post.dto.InscriptionPostDto; import com.cadac.stone_inscription.post.service.PostService; From 0e81bb3ecf78cbcb9278ab7e64343d358d365a91 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Fri, 29 May 2026 12:33:40 +0530 Subject: [PATCH 23/28] feat: admin dashboard and the fix for admin auth --- .../auth/JwtAuthenticationEntryPoint.java | 10 ++++++---- .../auth/controller/OAuthController.java | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/auth/JwtAuthenticationEntryPoint.java b/src/main/java/com/cadac/stone_inscription/auth/JwtAuthenticationEntryPoint.java index 3ae799c..5b50099 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/JwtAuthenticationEntryPoint.java +++ b/src/main/java/com/cadac/stone_inscription/auth/JwtAuthenticationEntryPoint.java @@ -3,10 +3,6 @@ import java.io.IOException; import java.util.Collections; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -14,6 +10,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { @@ -23,6 +23,8 @@ public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + System.out.println(request.getRequestURI() + "mohit"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index 33b95de..e2fd2cc 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -60,7 +60,7 @@ public class OAuthController { private String defaultProvider; - @PostMapping("/logout") + @PostMapping("/logout") @Operation( summary = "Logout", description = "Revokes the refresh token cookie when present and expires the browser cookie.", @@ -138,16 +138,18 @@ public void adminAuth( HttpServletResponse response) throws IOException { log.info("ADMIN_AUTH: storeFlow called provider={}", provider); oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); - response.sendRedirect("/oauth2/authorization/" + provider); + // response.sendRedirect("/oauth2/authorization/" + provider); + response.sendRedirect("https://inscriptions.cdacb.in/api/oauth2/authorization/" + provider); log.info("ADMIN_AUTH: redirected to /oauth2/authorization/{}", provider); } - @GetMapping("/admin/authorization") - public void adminAuthDefault( - HttpServletResponse response) throws IOException { - oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); - response.sendRedirect("/oauth2/authorization/" + defaultProvider); - } + // @GetMapping("/admin/authorization") + // public void adminAuthDefault( + // HttpServletResponse response) throws IOException { + // log.info("USER_AUTH: storeFlow called provider={}"); + // oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); + // response.sendRedirect("/oauth2/authorization/" + defaultProvider); + // } @GetMapping("/admin/approve") @Operation( From 41a92b28edc058d29a7d689c643555246b65b13e Mon Sep 17 00:00:00 2001 From: nayan458 Date: Mon, 1 Jun 2026 14:55:46 +0530 Subject: [PATCH 24/28] feat: admin refoctory done and is now working fine --- .../auth/controller/OAuthController.java | 14 ++++++++++++-- src/main/resources/application.properties | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index e2fd2cc..fc06729 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -138,11 +138,21 @@ public void adminAuth( HttpServletResponse response) throws IOException { log.info("ADMIN_AUTH: storeFlow called provider={}", provider); oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); - // response.sendRedirect("/oauth2/authorization/" + provider); - response.sendRedirect("https://inscriptions.cdacb.in/api/oauth2/authorization/" + provider); + response.sendRedirect("/oauth2/authorization/" + provider); // just this log.info("ADMIN_AUTH: redirected to /oauth2/authorization/{}", provider); } + // @GetMapping("/admin/authorization/{provider}") + // public void adminAuth( + // @PathVariable String provider, + // HttpServletResponse response) throws IOException { + // log.info("ADMIN_AUTH: storeFlow called provider={}", provider); + // oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); + // // response.sendRedirect("/oauth2/authorization/" + provider); + // response.sendRedirect("https://inscriptions.cdacb.in/api/oauth2/authorization/" + provider); + // log.info("ADMIN_AUTH: redirected to /oauth2/authorization/{}", provider); + // } + // @GetMapping("/admin/authorization") // public void adminAuthDefault( // HttpServletResponse response) throws IOException { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index afb70ea..3d415d2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -91,3 +91,6 @@ content.moderation.connect-timeout-ms=${CONTENT_MODERATION_CONNECT_TIMEOUT_MS} content.moderation.read-timeout-ms=${CONTENT_MODERATION_READ_TIMEOUT_MS} content.moderation.insecure-ssl=${CONTENT_MODERATION_INSECURE_SSL} ai.moderation.webhook-url=${AI_MODERATION_WEBHOOK_URL:https://inscriptions.cdacb.in/n8n/webhook/content-moderation} + +# HTTP -> HTTPS forward headers +server.forward-headers-strategy=framework \ No newline at end of file From 8601536c2776dd5bc9f752822e2fc13541971554 Mon Sep 17 00:00:00 2001 From: nayan458 Date: Thu, 7 May 2026 09:49:07 +0530 Subject: [PATCH 25/28] Added Issue Template --- .github/ISSUE_TEMPLATE/bug_report.md | 38 ++++++++++++++ .../component-task-issue-template.md | 46 ++++++++++++++++ .../ISSUE_TEMPLATE/feature-ticket-template.md | 47 +++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 +++++++ .../hook-data-task-issue-template.md | 47 +++++++++++++++++ .github/ISSUE_TEMPLATE/infra-config-task.md | 35 +++++++++++++ .../page-task--issue-template.md | 39 ++++++++++++++ .../test-task-issue-template.md | 52 +++++++++++++++++++ 8 files changed, 324 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/component-task-issue-template.md create mode 100644 .github/ISSUE_TEMPLATE/feature-ticket-template.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/hook-data-task-issue-template.md create mode 100644 .github/ISSUE_TEMPLATE/infra-config-task.md create mode 100644 .github/ISSUE_TEMPLATE/page-task--issue-template.md create mode 100644 .github/ISSUE_TEMPLATE/test-task-issue-template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..f12a8a1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: 'BUG: ' +labels: enhancement +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/component-task-issue-template.md b/.github/ISSUE_TEMPLATE/component-task-issue-template.md new file mode 100644 index 0000000..f6535b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/component-task-issue-template.md @@ -0,0 +1,46 @@ +--- +name: Component Task issue template +about: Create a report to describe component functionality +title: 'COMPONENT: ' +labels: enhancement +assignees: '' +type: Task + +--- + +## Task: [ComponentName] Component +**Type:** Component +**Feature:** #12 Login Flow +**Estimate:** [S / M / L] + +### Component Type +- [ ] Design system (no API calls, pure props) +- [ ] Feature component (may use hooks) + +### Props Interface +```ts +interface LoginFormProps { + onSuccess: (user: User) => void + isLoading?: boolean + error?: string | null +} +``` + +### Variants / States +| State | Description | +|-------|-------------| +| Default | Empty form, submit enabled | +| Loading | Inputs disabled, button shows spinner | +| Error | Error message shown below form | +| Success | Handled by parent via onSuccess callback | + +### Acceptance Criteria +- [ ] Renders all states correctly +- [ ] Calls onSuccess with user data on valid submit +- [ ] Shows inline validation (email format, required fields) +- [ ] Does not call API directly (hook handles that) +- [ ] Unit tested (required fields, error display, onSuccess called) + +### Notes +- Email field: type="email", autocomplete="email" +- Password field: toggle visibility icon diff --git a/.github/ISSUE_TEMPLATE/feature-ticket-template.md b/.github/ISSUE_TEMPLATE/feature-ticket-template.md new file mode 100644 index 0000000..edf4252 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-ticket-template.md @@ -0,0 +1,47 @@ +--- +name: Feature Ticket Template +about: Feature implementation Ticket +title: 'Feature: ' +labels: enhancement, feature +assignees: '' +type: Feature + +--- + +## Feature: [Name] — e.g., "Login Flow" + +**Milestone:** Auth System +**Route(s):** /login +**Owner:** @username + +--- + +### Context +What problem does this solve? Who uses it? +→ "Allows existing users to authenticate and reach the dashboard." + +### API Contract +| Method | Endpoint | Request | Response | +|--------|----------|---------|----------| +| POST | /api/auth/login | { email, password } | { token, user } | +| POST | /api/auth/logout | — | 204 | + +### UI States to Handle +- [ ] Default (empty form) +- [ ] Loading (submit in progress) +- [ ] Validation error (client-side, before submit) +- [ ] Server error (wrong credentials, 401) +- [ ] Success (redirect to /dashboard) + +### Tasks +- [ ] #21 — LoginForm component +- [ ] #22 — useLogin mutation hook +- [ ] #23 — Token storage + Axios interceptor +- [ ] #24 — Integration tests + +### Out of Scope +- Password reset (separate feature #31) +- SSO / OAuth (milestone 3) + +### Design Reference +[Figma link or screenshot] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..28b070d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement, feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/hook-data-task-issue-template.md b/.github/ISSUE_TEMPLATE/hook-data-task-issue-template.md new file mode 100644 index 0000000..3a94b3c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hook-data-task-issue-template.md @@ -0,0 +1,47 @@ +--- +name: Hook/Data Task issue template +about: Create a report to describe hook or data requirements. +title: 'HOOK: ' +labels: 'enhancement, type: hook' +assignees: '' +type: Task + +--- + +## Task: [hookName] Hook +**Type:** Hook +**Feature:** #12 Login Flow +**Estimate:** [S / M / L] + +### Hook Signature +```ts +const { mutate: login, isPending, error } = useLogin() +``` + +### API Call +| Method | Endpoint | Auth required | +|--------|----------|---------------| +| POST | /api/auth/login | No | + +### Request +```ts +{ email: string, password: string } +``` + +### Response +```ts +{ token: string, refreshToken: string, user: User } +``` + +### Behavior +- On success: store token in httpOnly cookie (or localStorage per arch decision) +- On success: invalidate ['auth', 'me'] query +- On success: call onSuccess callback +- On 401: surface "Invalid email or password" message +- On 5xx: surface "Something went wrong, try again" + +### Acceptance Criteria +- [ ] Token stored correctly on success +- [ ] Error messages mapped from status codes +- [ ] Unit tested: success case, 401 case, 5xx case +- [ ] No token stored on failure diff --git a/.github/ISSUE_TEMPLATE/infra-config-task.md b/.github/ISSUE_TEMPLATE/infra-config-task.md new file mode 100644 index 0000000..0aad3ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/infra-config-task.md @@ -0,0 +1,35 @@ +--- +name: Infra/Config Task +about: Infrastructure related and configuration specific issues. +title: 'CONFIG: ' +labels: 'enhancement, type: infra' +assignees: '' +type: Task + +--- + +## Task: [Setup Name] — e.g., "Axios Instance + Interceptors" +**Type:** Infrastructure +**Milestone:** Project Setup +**Estimate:** [S / M / L] + +### What This Enables +Which features are blocked until this is done? +→ "All API hooks depend on this. Blocks entire auth milestone." + +### Deliverables +- [ ] `src/lib/axios.ts` — configured instance with base URL +- [ ] Request interceptor — attaches Bearer token from storage +- [ ] Response interceptor — handles 401 (trigger refresh), 5xx (log + rethrow) +- [ ] Token refresh logic — queues failed requests during refresh + +### Acceptance Criteria +- [ ] All requests include Authorization header when token exists +- [ ] 401 triggers token refresh, retries original request once +- [ ] 5xx errors are logged and rethrown as readable Error objects +- [ ] Unit tested: token attachment, 401 refresh, 5xx handling +- [ ] No raw fetch() calls anywhere else in the codebase + +### Notes +- Use `axios-auth-refresh` library or implement manually — decide and document +- Refresh endpoint: POST /api/auth/refresh diff --git a/.github/ISSUE_TEMPLATE/page-task--issue-template.md b/.github/ISSUE_TEMPLATE/page-task--issue-template.md new file mode 100644 index 0000000..cf529b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/page-task--issue-template.md @@ -0,0 +1,39 @@ +--- +name: Page Task issue template +about: Create a report to describe a task that needs to be developed. +title: 'Page: ' +labels: 'enhancement, type: page' +assignees: '' +type: Task + +--- + +## Task: [Page Name] Page +**Type:** Page +**Feature:** #12 Login Flow +**Estimate:** [S / M / L] + +### Route +`/login` — public, redirects to /dashboard if already authenticated + +### Components Used +- [ ] LoginForm (Task #21 — new) +- [ ] Button (design system) +- [ ] Input (design system) +- [ ] Toast (design system — for server errors) + +### Behavior +- Redirect to /dashboard on successful login +- Redirect to /login if user hits protected route unauthenticated +- Preserve `?redirect=` query param and use it after login + +### Acceptance Criteria +- [ ] Page renders without console errors +- [ ] Works on mobile (375px) and desktop (1280px) +- [ ] Loading state visible during submit +- [ ] Error message appears on wrong credentials +- [ ] Passes keyboard navigation (Tab through fields, Enter submits) + +### Out of Scope +- "Remember me" checkbox +- Social login buttons diff --git a/.github/ISSUE_TEMPLATE/test-task-issue-template.md b/.github/ISSUE_TEMPLATE/test-task-issue-template.md new file mode 100644 index 0000000..de4cc86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/test-task-issue-template.md @@ -0,0 +1,52 @@ +--- +name: Test Task issue template +about: Describe this issue template's purpose here. +title: 'TEST: ' +labels: 'enhancement, type: test' +assignees: '' +type: Task + +--- + +## Task: Tests — [Feature Name] +**Type:** Tests +**Feature:** #12 Login Flow +**Estimate:** [S / M / L] + +### Scope +- [ ] Integration tests (RTL + MSW) +- [ ] Unit tests (hooks/utils) +- [ ] E2E test (Playwright) — only if critical flow + +### Test Cases + +**LoginForm component** +- [ ] Renders email and password fields +- [ ] Shows required error when submitting empty form +- [ ] Shows invalid email error on bad format +- [ ] Disables inputs and button while loading +- [ ] Displays error prop message + +**useLogin hook** +- [ ] Stores token on 200 response +- [ ] Returns error message on 401 +- [ ] Returns generic error on 500 + +**Login Page (integration)** +- [ ] Shows skeleton/loading state while submitting +- [ ] Redirects to /dashboard on success +- [ ] Shows toast on server error +- [ ] Preserves ?redirect= param after login + +**E2E (Playwright)** +- [ ] User can log in with valid credentials and reach dashboard + +### MSW Handlers Needed +- POST /api/auth/login → 200 (happy path) +- POST /api/auth/login → 401 (wrong credentials) +- POST /api/auth/login → 500 (server error) + +### Acceptance Criteria +- [ ] All cases above pass +- [ ] Coverage does not drop below threshold +- [ ] No tests rely on implementation details (no testing state directly) From a96bb135d832a41f0bab7f6ce174b7b4cfb0f84f Mon Sep 17 00:00:00 2001 From: nayan458 Date: Thu, 7 May 2026 14:06:48 +0530 Subject: [PATCH 26/28] Added new Ci --- .github/workflows/ci.yml | 47 --------------------------- .github/workflows/develop-deploy.yml | 46 ++++++++++++++++++++++++++ .github/workflows/pr-validation.yml | 38 ++++++++++++++++++++++ .github/workflows/release.yml | 48 ++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 47 deletions(-) delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/develop-deploy.yml create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 917ccca..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: CI/CD Pipeline - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Maven dependencies - uses: actions/cache@v4 - with: - path: ~/.m2 - key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} - restore-keys: ${{ runner.os }}-m2 - - - name: Build with Maven - run: mvn clean compile - - # - name: Run tests - # run: mvn test - - - name: Build Docker image - run: docker build -t nayanmoni458/stoneinscription:backend-new . - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: Push Docker image - run: docker push nayanmoni458/stoneinscription:backend-new - \ No newline at end of file diff --git a/.github/workflows/develop-deploy.yml b/.github/workflows/develop-deploy.yml new file mode 100644 index 0000000..a8a5ed6 --- /dev/null +++ b/.github/workflows/develop-deploy.yml @@ -0,0 +1,46 @@ +name: Develop Deployment + +on: + push: + branches: + - develop + +jobs: + build-and-push-dev: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Build Application + run: mvn clean verify + + - name: Build Docker Image + run: | + docker build \ + -t nayanmoni458/stoneinscription:dev-latest \ + -t nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} . + + - name: Login To Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Push Docker Images + run: | + docker push nayanmoni458/stoneinscription:dev-latest + docker push nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} \ No newline at end of file diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..5c8831d --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,38 @@ +name: PR Validation + +on: + pull_request: + branches: + - develop + - main + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2 + + - name: Compile Project + run: mvn clean compile + + # - name: Run Unit Tests + # run: mvn test + + - name: Run Verify + run: mvn verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..493083d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: Release Pipeline + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Cache Maven Dependencies + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Run Full Validation + run: mvn clean verify + + - name: Build Docker Image + run: | + docker build \ + -t nayanmoni458/stoneinscription:${GITHUB_REF_NAME} \ + -t nayanmoni458/stoneinscription:latest \ + -t nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} . + + - name: Login To Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Push Docker Images + run: | + docker push nayanmoni458/stoneinscription:${GITHUB_REF_NAME} + docker push nayanmoni458/stoneinscription:latest + docker push nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} \ No newline at end of file From cb6adca4fbdcd98001bd0f84ed80287210f707df Mon Sep 17 00:00:00 2001 From: nayan458 Date: Thu, 7 May 2026 14:32:39 +0530 Subject: [PATCH 27/28] Removed unnecessary files --- ApiDocumentation.MD | 142 ------ LLD.MD | 411 ---------------- SECURITY.md | 21 - SystemArchitecture.md | 1072 ----------------------------------------- Test.MD | 180 ------- 5 files changed, 1826 deletions(-) delete mode 100644 ApiDocumentation.MD delete mode 100644 LLD.MD delete mode 100644 SECURITY.md delete mode 100644 SystemArchitecture.md delete mode 100644 Test.MD diff --git a/ApiDocumentation.MD b/ApiDocumentation.MD deleted file mode 100644 index 165d07e..0000000 --- a/ApiDocumentation.MD +++ /dev/null @@ -1,142 +0,0 @@ -# API Documentation for Stone Inscription Application - -This document outlines the API endpoints of the Stone Inscription application, organized by Functional Requirements (FR) and Non-Functional Requirements (NFR). Each FR/NFR includes the responsible APIs with brief descriptions. - -## Functional Requirements - -### FR1: User Authentication and Authorization -Responsible for handling user login, logout, token management, and session tracking. - -- **FR1.1: User Logout** - API: `POST /oauth2/logout` - Details: Logs out the authenticated user by invalidating tokens and clearing session data. - -- **FR1.2: Token Refresh** - API: `POST /oauth2/authenticated/refresh-token` - Details: Refreshes the JWT access token using a valid refresh token to maintain user session. - -- **FR1.3: Update User Activity** - API: `POST /oauth2/authenticated/active` - Details: Updates the user's last active timestamp to track session activity. - -### FR2: User Profile Management -Handles user profile retrieval, updates, and image uploads. - -- **FR2.1: Retrieve User Profile** - API: `GET /user/profile` - Details: Fetches the current authenticated user's profile information. - -- **FR2.2: Update User Profile** - API: `POST /user/updateProfile` - Details: Updates the user's profile details, such as username. - -- **FR2.3: Upload Profile Image** - API: `POST /user/uploadProfileImage` - Details: Uploads or updates the user's profile picture. - -- **FR2.4: Upload Cover Image** - API: `POST /user/uploadCoverImage` - Details: Uploads or updates the user's cover image. - -- **FR2.5: Retrieve User Images** - API: `GET /user/public/images/{id}` - Details: Publicly accessible endpoint to retrieve user images (profile or cover) by ID. - -### FR3: Post Management -Manages creation, retrieval, and deletion of inscription posts. - -- **FR3.1: Create Post with Files** - API: `POST /post/addPostWithFile` - Details: Creates a new inscription post with attached image files. - -- **FR3.2: Retrieve All Posts** - API: `POST /post/getAllPost` - Details: Fetches all available inscription posts. - -- **FR3.3: Retrieve User's Posts** - API: `POST /post/getAllUserPost` - Details: Fetches all posts created by the authenticated user. - -- **FR3.4: Delete Post** - API: `POST /post/postDelete` - Details: Deletes a specific post by the authenticated user. - -- **FR3.5: Retrieve Post Images** - API: `GET /post/public/images/{id}` - Details: Publicly accessible endpoint to retrieve post images by ID. - -### FR4: Post Description Management -Handles adding, retrieving, updating, and deleting descriptions for posts. - -- **FR4.1: Add Post Description** - API: `POST /post/addPoastDiscription` - Details: Adds a description to a specific post. - -- **FR4.2: Retrieve Post Description** - API: `POST /post/getPostDiscription` - Details: Fetches the description of a specific post. - -- **FR4.3: Update Post Description** - API: `POST /post/updatePostDiscription` - Details: Updates an existing description for a post. - -- **FR4.4: Delete Post Description** - API: `POST /post/discriptionDelete` - Details: Deletes a specific description from a post. - -### FR5: Post Interactions -Manages user interactions like ratings and votes on posts. - -- **FR5.1: Add Rating to Post** - API: `POST /post/addRating` - Details: Allows users to rate a post with a numerical value. - -- **FR5.2: Add Vote to Description** - API: `POST /post/addVote` - Details: Allows users to vote on a post description. - -### FR6: User Profile in Context -Provides user profile information within the post context. - -- **FR6.1: Retrieve User Profile for Posts** - API: `POST /post/userProfile` - Details: Fetches the authenticated user's profile information in the context of post operations. - -## Non-Functional Requirements - -### NFR1: Error Handling -Ensures consistent error responses across the application. - -- **NFR1.1: Custom Exception Handling** - API: Global exception handlers in `ExceptionController` - Details: Handles `StoneInscriptionException` and validation errors, providing standardized error responses with HTTP status codes. - -- **NFR1.2: Validation Error Handling** - API: Global exception handlers in `ExceptionController` - Details: Manages `MethodArgumentNotValidException` for request body validation, returning field-specific error messages. - -### NFR2: Security -Implements authentication and authorization mechanisms. - -- **NFR2.1: JWT Token Validation** - API: Applied via `JwtRequestFilter` and `@Secured` annotations - Details: Validates JWT tokens on protected endpoints, ensuring only authenticated users can access secured resources. - -- **NFR2.2: OAuth2 Integration** - API: Handled by Spring Security OAuth2 configuration - Details: Supports OAuth2 login flows (e.g., Google), with success handlers managing user authentication and registration. - -- **NFR2.3: CSRF Protection** - API: Implicit in Spring Security configuration - Details: Protects against Cross-Site Request Forgery attacks on state-changing operations. - -### NFR3: Performance and Scalability -Addresses response times and resource handling. - -- **NFR3.1: File Upload Validation** - API: Validation in `PostController.addPostWithFile` - Details: Validates file types and sizes during upload to prevent resource exhaustion. - -- **NFR3.2: Image Serving Optimization** - API: `GET /post/public/images/{id}` and `GET /user/public/images/{id}` - Details: Serves images efficiently as `InputStreamResource` for public access without authentication overhead. \ No newline at end of file diff --git a/LLD.MD b/LLD.MD deleted file mode 100644 index bbe5d6f..0000000 --- a/LLD.MD +++ /dev/null @@ -1,411 +0,0 @@ -# Low-Level Design (LLD) Documentation for Stone Inscription Application - -This document provides detailed low-level design specifications for the Stone Inscription application, including system architecture, database schema, class designs, and component interactions. - -## System Architecture - -The application follows a layered architecture based on Spring Boot: - -### Layers -1. **Presentation Layer**: REST Controllers handling HTTP requests/responses -2. **Service Layer**: Business logic implementation -3. **Repository Layer**: Data access using Spring Data MongoDB -4. **Entity Layer**: Domain objects mapped to MongoDB collections - -### Key Components -- **Authentication Module**: Handles OAuth2 login, JWT token management -- **User Management Module**: Profile management, image uploads -- **Post Management Module**: Inscription post CRUD, ratings, descriptions -- **Image Management Module**: File upload, storage, and retrieval -- **Moderation Module**: Content moderation integration -- **Exception Handling**: Global error handling and responses - -### Technology Stack -- **Framework**: Spring Boot 3.5.5 -- **Database**: MongoDB -- **Security**: Spring Security with OAuth2, JWT -- **File Storage**: MongoDB GridFS for images -- **Build Tool**: Maven -- **Language**: Java 17 - -## Database Schema - -The application uses MongoDB with the following collections: - -### 1. users_auth -Stores user authentication information. - -**Fields**: -- `_id` (ObjectId): Primary key -- `username` (String): Unique username -- `email` (String): Unique email address -- `passwordHash` (String): Hashed password (ignored in JSON) -- `provider` (String): OAuth provider (e.g., Google) -- `roles` (List): User roles -- `createdAt` (Date): Creation timestamp -- `updatedAt` (Date): Last update timestamp -- `lastLogin` (Date): Last login timestamp - -**Indexes**: username (unique), email (unique) - -### 2. users_profile -Stores user profile information. - -**Fields**: -- `_id` (ObjectId): Primary key -- `authId` (ObjectId): Reference to users_auth._id -- `name` (String): Full name -- `username` (String): Unique username -- `email` (String): Email address -- `profileImage` (String): Profile image URL/path -- `coverImage` (String): Cover image URL/path -- `bio` (String): User biography -- `imagesUploaded` (Integer): Count of uploaded images -- `upvotesReceived` (Integer): Total upvotes received -- `followers` (Integer): Number of followers -- `points` (Integer): User points/score -- `createdAt` (Date): Creation timestamp -- `updatedAt` (Date): Last update timestamp -- `active` (Boolean): Account active status - -**Indexes**: name, username (unique), email (unique), createdAt - -### 3. inscriptionposts -Stores inscription post data. - -**Fields**: -- `_id` (ObjectId): Primary key -- `user_id` (ObjectId): Reference to users_profile._id -- `username` (String): Transient field for user name -- `createdAt` (Date): Creation timestamp -- `updatedAt` (Date): Last update timestamp -- `images` (Embedded): Image data (thumbnail and list) -- `description` (Embedded): Post description details -- `topic` (String): Post topic/category -- `script` (List): Script types -- `type` (String): Post type -- `visiblity` (Boolean): Post visibility -- `distance` (Double): Distance metric -- `rating` (Double): Average rating -- `userrating` (List): User ratings - -**Embedded Objects**: -- **Images**: - - `thumbnailImage` (String): Thumbnail URL - - `image` (List): Image URLs -- **Description**: - - `title` (String): Post title - - `subject` (String): Subject - - `description` (String): Description text - - `scriptLanguage` (List): Script languages - - `language` (List): Languages - - `englishTranslation` (String): English translation - - `moderation` (ContentModeration): Moderation status - -**Indexes**: user_id, topic, createdAt (compound indexes) - -### 4. postdescription -Stores public descriptions for posts. - -**Fields**: -- `_id` (ObjectId): Primary key -- `postId` (ObjectId): Reference to inscriptionposts._id -- `userId` (ObjectId): Reference to users_profile._id -- `username` (String): User name -- `userImageUrl` (String): User image URL -- `description` (String): Description text -- `moderation` (ContentModeration): Moderation status -- `upvote` (Integer): Upvote count -- `userVote` (List): User votes -- `createdAt` (Date): Creation timestamp -- `updatedAt` (Date): Last update timestamp - -**Embedded Objects**: -- **UserVote**: - - `userId` (String): Voting user ID - -### 5. imagesdata -Stores binary image data for posts. - -**Fields**: -- `id` (String): Primary key -- `postId` (ObjectId): Reference to inscriptionposts._id -- `imageData` (byte[]): Binary image data -- `metadata` (Embedded): Image metadata - -**Embedded Objects**: -- **Metadata**: - - `fileName` (String): Original file name - - `fileSize` (Long): File size in bytes - - `contentType` (String): MIME type - - `imageHashValue` (String): Image hash for deduplication - -### 6. user_images -Stores binary image data for user profiles. - -**Fields**: -- `id` (String): Primary key -- `userId` (ObjectId): Reference to users_profile._id -- `imageType` (Enum): PROFILE or COVER -- `imageData` (byte[]): Binary image data -- `metadata` (Embedded): Image metadata - -**Embedded Objects**: -- **Metadata**: - - `fileName` (String): Original file name - - `fileSize` (Long): File size in bytes - - `contentType` (String): MIME type - -## Relationships - -- `users_auth` ↔ `users_profile`: One-to-One (authId in users_profile references users_auth._id) -- `users_profile` ↔ `inscriptionposts`: One-to-Many (user_id in inscriptionposts references users_profile._id) -- `users_profile` ↔ `user_images`: One-to-Many (userId in user_images references users_profile._id) -- `inscriptionposts` ↔ `imagesdata`: One-to-Many (postId in imagesdata references inscriptionposts._id) -- `inscriptionposts` ↔ `postdescription`: One-to-Many (postId in postdescription references inscriptionposts._id) - -## Class Design - -### Controllers -- **OAuthController**: Handles authentication endpoints -- **UserController**: Manages user profile operations -- **PostController**: Handles post CRUD and interactions -- **ExceptionController**: Global exception handling - -### Services -- **StoneAuthService**: Authentication and token management -- **UserService**: User profile business logic -- **PostService**: Post management business logic - -### Repositories -- **UserRepository**: User data access -- **UserAuthRepository**: Authentication data access -- **InscriptionPostRepository**: Post data access -- **PublicPostDescriptionRepository**: Description data access -- **ImagesDataRepository**: Image data access -- **UserImageRepository**: User image data access - -### Entities -- **User**: User profile entity -- **UserAuth**: User authentication entity -- **InscriptionPost**: Post entity with embedded objects -- **PublicPostDescription**: Post description entity -- **ImagesData**: Post image data entity -- **UserImage**: User image data entity - -### Utilities -- **JwtUtil**: JWT token operations -- **StoneInscriptionUserDetailservice**: User details for authentication - -## API Design - -Detailed API specifications are documented in `ApiDocumentation.MD`. Key design principles: - -- RESTful endpoints with proper HTTP methods -- JWT-based authentication for secured endpoints -- File upload support with multipart/form-data -- Consistent error response format -- Pagination and filtering support - -## Data Flow - -### User Registration/Login Flow -1. User initiates OAuth2 login (e.g., Google) -2. OAuth2SuccessHandler processes authentication -3. User profile created/updated in database -4. JWT token generated and returned - -### Post Creation Flow -1. Authenticated user uploads files and post data -2. Files validated and stored in ImagesData collection -3. Post entity created with image references -4. Moderation service called for content check -5. Post saved to inscriptionposts collection - -### Image Retrieval Flow -1. Client requests image by ID -2. Repository fetches image data from ImagesData/UserImage -3. Binary data streamed as InputStreamResource -4. Content-Type header set based on metadata - -## Module Interaction Flows - -The following module flows are presented in dependency order. If one flow depends on another service, the dependent module is listed first. - -### 1. Authentication Module Flow -Dependencies: `JwtRequestFilter` -> `JwtUtil` -> `StoneInscriptionUserDetailservice` -> `UserRepository` -> `RefreshTokenRepo` -> `StoneAuthServiceImp` -> `OAuthController` - -1. Request arrives at `OAuthController` or any secured controller endpoint. -2. `JwtRequestFilter` intercepts the request and calls `extractJwtFromRequest()`. -3. `JwtUtil` validates the token and extracts the username and roles. -4. If valid, the filter creates a `UsernamePasswordAuthenticationToken` and sets it into `SecurityContextHolder`. -5. Secured controller methods (`@Secured("user")`) execute with authenticated context. -6. For refresh token flow, `OAuthController.refreshToken()` delegates to `StoneAuthServiceImp.refreshToken()`. -7. `StoneAuthServiceImp` reads the `refreshToken` cookie, hashes it, and queries `RefreshTokenRepo`. -8. It validates the refresh token state, expiration, and revoke flag. -9. It loads the `User` from `UserRepository` and uses `StoneInscriptionUserDetailservice` plus `JwtUtil` to generate a new access token. -10. It rotates the refresh token by saving a new `RefreshToken` object and sending a refreshed cookie back to the client. -11. For logout, `StoneAuthServiceImp.logoutAuth()` revokes the refresh token and deletes the cookie. -12. For active session updates, `StoneAuthServiceImp.updateLastActive()` validates the refresh token and updates `lastUseAt`. - -### 2. User Management Flow -Dependencies: `Authentication Module` -> `UserController` -> `UserServiceImpl` -> `UserRepository` -> `UserImageRepo` - -1. Authenticated requests to `UserController` endpoints include `Authorization: Bearer `. -2. `JwtRequestFilter` authenticates the request and populates the security context. -3. `UserController` extracts the user email from the token and delegates to `UserServiceImpl`. -4. `UserServiceImpl.getProfile()` fetches the `User` entity from `UserRepository`. -5. `UserServiceImpl.updateProfile()` validates input and updates the user record. -6. `UserServiceImpl.uploadProfileImage()` and `uploadCoverImage()` validate file extension, delete any existing image of the same type, save the new `UserImage`, update the user record, and return the updated profile. -7. `getUserImage()` streams binary image bytes from `UserImageRepo`. - -### 3. Image Validation and Geolocation Module Flow -Dependencies: `User Management Flow` for user identity -> `PostServiceImp` -> `ImageMetadataGeolocationWithPhash` -> `ImagesDataRepo` - -1. `PostController.addPostWithFile()` receives multipart file uploads and calls `PostServiceImp.addPostWithFile()`. -2. `PostServiceImp` loads the user using `UserRepository` and validates the files. -3. It calls `ImageMetadataGeolocationWithPhash.getGeoLocationWithIamgeMetaandInfo()` to: - - compute perceptual hash (`pHash`) - - read EXIF metadata - - extract GPS coordinates if present - - call external geolocation API to map coordinates to address details -4. The helper returns metadata objects for each image. -5. `PostServiceImp` may use geolocation output to populate `InscriptionPost.Description.GeoLocation`. -6. It saves binary image data into `ImagesDataRepo` and stores returned image IDs in `InscriptionPost.Images`. - -### 4. Content Moderation Module Flow -Dependencies: `Image Validation and Geolocation Module` -> `PostServiceImp` -> `ContentModerationService` -> `N8nModerationClient` -> External moderation webhook - -1. When creating or updating posts or descriptions, `PostServiceImp` calls `moderatePostContent()` or `moderateCommentContent()`. -2. These helper methods delegate to `ContentModerationService.moderate(title, topic, description)`. -3. `ContentModerationService` validates all required fields and builds a `ContentModerationRequestDto`. -4. It calls `N8nModerationClient.moderate()` to send the payload to the external moderation webhook. -5. The service parses the raw JSON response into `ContentModerationResponseDto` objects. -6. It evaluates `decision`, `confidence`, and `status` against service thresholds. -7. If the content is rejected, `ContentModerationService` throws `StoneInscriptionException` and aborts the save. -8. If approved, it returns a `ContentModerationResult` that is converted into embedded `ContentModeration` metadata in the post or description record. - -### 5. Post Management Module Flow -Dependencies: `Authentication Module` -> `UserRepository` -> `PostServiceImp` -> `Image Validation and Geolocation Module` -> `Content Moderation Module` -> `InscriptionPostRepo` / `PublicPostDescriptionRepo` / `ImagesDataRepo` - -1. `PostController.addPostWithFile()` and other secured endpoints receive requests from authenticated users. -2. `PostServiceImp.addPostWithFile()` loads the current user and validates image files. -3. It calls image metadata extraction and optionally geolocation lookup. -4. It calls content moderation before persisting the post description. -5. If moderation approves, it creates or updates the `InscriptionPost` entity and saves it through `InscriptionPostRepo`. -6. It stores raw image bytes in `ImagesDataRepo` and updates the post with image IDs and thumbnail references. -7. `PostServiceImp.getAllPost()` and `getAllUserPost()` enrich post records with user names and public image URLs using `backendUrl`. -8. `addPoastDiscription()`, `updatePostDiscription()`, and description delete operations validate ownership, moderate content, and persist to `PublicPostDescriptionRepo`. -9. Post vote and rating updates also validate user identity and then update the relevant post or description records. - -### 6. Exception and Security Filter Flow -Dependencies: `Authentication Module` -> `JwtRequestFilter` -> Controller layer -> `ExceptionController` - -1. The request filter validates JWT and attaches authentication or request exceptions. -2. If a token is invalid or missing, the filter passes the exception to the request. -3. Controller methods may throw `StoneInscriptionException` for invalid business logic, authorization failures, or moderation failures. -4. `ExceptionController` catches these exceptions globally and returns a standardized JSON response. - -### Topological Order Summary -1. `JwtRequestFilter` / `JwtUtil` / `StoneInscriptionUserDetailservice` (Authentication infrastructure) -2. `StoneAuthServiceImp` / `RefreshTokenRepo` / `UserRepository` (Token lifecycle) -3. `UserController` / `UserServiceImpl` / `UserImageRepo` (User profile management) -4. `PostServiceImp` / `ImageMetadataGeolocationWithPhash` / `ImagesDataRepo` (Image validation and storage) -5. `ContentModerationService` / `N8nModerationClient` (Moderation) -6. `InscriptionPostRepo` / `PublicPostDescriptionRepo` (Post persistence) -7. `ExceptionController` (Global error handling) - -## Deployment Consideration for Module Dependencies -- The authentication module must be initialized before secured controller execution. -- Moderation infrastructure must be available when post creation or description submission is invoked. -- Geolocation API dependency is required only when uploaded images contain GPS EXIF data. -- Image storage persistence must complete before the post entity is marked published. - -## Module Interaction Notes -- The **Authentication Module** is the foundation: if token verification fails, all secured user and post flows abort. -- The **Post Management Module** is dependent on both **Image Validation** and **Content Moderation**. -- User profile image uploads are independent of post moderation but still require authenticated user context. -- External services include the geolocation API for GPS data and the moderation webhook for content approval. - -## Diagram Guidance -A complete interaction diagram should show: -- OAuth flow into Authentication Module -- Token validation into secured controllers -- User service and on-demand image service branching from authenticated user actions -- Post creation flow feeding into Image Validation and Content Moderation before persistence -- Exception handling as a final sink for failures - -## Result -This ordered flow description provides clear module dependencies and explicit sequencing for the authentication, user, image, moderation, and post services in the Stone Inscription application. - -## Security Design - -### Authentication -- OAuth2 integration with Google provider -- JWT tokens for session management -- Refresh token mechanism for token renewal - -### Authorization -- Role-based access control with @Secured annotations -- User-scoped operations (users can only modify their own data) - -### Data Protection -- Passwords hashed (though OAuth2 primary) -- Sensitive fields excluded from JSON serialization -- CSRF protection enabled - -### Input Validation -- Bean validation annotations (@NotBlank, @Email, etc.) -- File type and size validation for uploads -- Custom validation for business rules - -## Error Handling - -### Global Exception Handling -- **StoneInscriptionException**: Custom business exceptions -- **MethodArgumentNotValidException**: Validation errors -- **Authentication/Authorization Exceptions**: Security violations - -### Error Response Format -```json -{ - "error_message": "Error description", - "http_status": 400, - "http_status_code": 400 -} -``` - -## Performance Considerations - -### Database Optimization -- Strategic indexing on frequently queried fields -- Compound indexes for multi-field queries -- Embedded documents to reduce joins - -### Image Handling -- Thumbnail generation for efficient loading -- Lazy loading of image data -- Hash-based deduplication - -### Caching -- Potential for Redis caching of user profiles and popular posts -- JWT token caching for performance - -## Deployment Considerations - -### Environment Configuration -- Application properties for different environments -- MongoDB connection configuration -- OAuth2 client credentials - -### Monitoring -- Spring Boot Actuator for health checks -- Micrometer for metrics collection -- Prometheus integration for monitoring - -### Scalability -- Horizontal scaling with multiple instances -- Database sharding for large datasets -- CDN integration for image serving - -Date: 11 April 2026 \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index f7474ff..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,21 +0,0 @@ -# Security Policy Template -Need to be updated -## Supported Versions - -Use this section to tell people about which versions of your project are -currently being supported with security updates. - -| Version | Supported | -| ------- | ------------------ | -| 5.1.x | :white_check_mark: | -| 5.0.x | :x: | -| 4.0.x | :white_check_mark: | -| < 4.0 | :x: | - -## Reporting a Vulnerability - -Use this section to tell people how to report a vulnerability. - -Tell them where to go, how often they can expect to get an update on a -reported vulnerability, what to expect if the vulnerability is accepted or -declined, etc. diff --git a/SystemArchitecture.md b/SystemArchitecture.md deleted file mode 100644 index fb8d1d9..0000000 --- a/SystemArchitecture.md +++ /dev/null @@ -1,1072 +0,0 @@ -# System Architecture Documentation -## Artifact Registry Backend - ---- - -## Table of Contents -1. [System Architecture](#1-system-architecture) - - 1.1 [System Software Layer](#11-system-software-layer) - - 1.2 [Audit](#12-audit) - - 1.3 [Security](#13-security) - - 1.4 [Components Interface Design](#14-components-interface-design) - ---- - -## 1. System Architecture - -### Overview -The Artifact Registry Backend is a modern, microservices-ready Spring Boot 3.5.5 application built with Java 17. The system is designed to manage artifact inscriptions with multi-layered architecture supporting authentic user authentication, content moderation, and comprehensive security controls. - -**Core Technologies:** -- **Framework:** Spring Boot 3.5.5 -- **Language:** Java 17 -- **Database:** MongoDB (with Auditing enabled) -- **Authentication:** OAuth2 (Google, Facebook, Apple) + JWT -- **Monitoring:** Prometheus, Micrometer, Spring Actuator -- **Containerization:** Docker, Docker Compose - ---- - -## 1.1 System Software Layer - -### 1.1.1 Architecture Layers - -``` -┌─────────────────────────────────────────────────────────┐ -│ API Layer │ -│ (REST Endpoints via Controllers) │ -├─────────────────────────────────────────────────────────┤ -│ Business Logic Layer │ -│ (Services & Processors) │ -├─────────────────────────────────────────────────────────┤ -│ Data Access Layer │ -│ (Repositories & DAOs) │ -├─────────────────────────────────────────────────────────┤ -│ Database Layer │ -│ (MongoDB Collections) │ -└─────────────────────────────────────────────────────────┘ -``` - -### 1.1.2 Component Structure - -#### **Authentication & Authorization Layer** -- **JwtUtil:** JWT token generation, validation, and claims management using Nimbus JOSE -- **JwtAuthenticationEntryPoint:** Entry point for unauthorized requests -- **JwtRequestFilter:** Filter chain processor for JWT validation on each request -- **CustomOAuth2SuccessHandler:** Handles successful OAuth2 authentication flows -- **StoneInscriptionUserDetailservice:** Custom UserDetails service for user information loading - -#### **Core Application Components** - -**1. User Management Module** (`user/`) -- User profile management and retrieval -- Profile image and cover image uploads -- User activity tracking -- Components: - - Controllers: Handle HTTP requests for user operations - - Services: Business logic for user operations - - DTOs: Data transfer objects for API communication - -**2. Post Management Module** (`post/`) -- Create, retrieve, update, and delete posts (inscriptions) -- Post image management -- Post description handling with versioning -- Components: - - Controllers: HTTP endpoints for post operations - - Services: Business logic for post management - - Mappers: Entity-DTO conversions - - DTOs: Data structures for API communication - -**3. Moderation Module** (`moderation/`) -- Content moderation and validation -- External moderation service integration -- Flag inappropriate content -- Components: - - Client: External API communication - - Config: Moderation configuration - - Service: Moderation business logic - - Model: Moderation data structures - - DTO: Data transfer objects - -**4. Entity Layer** (`entity/`) -- **User:** User profile information -- **UserAuth:** Authentication credentials and OAuth provider details -- **InscriptionPost:** Post/inscription content -- **PublicPostDescription:** Post descriptions with versioning -- **UserImage:** User profile and cover images -- **ImagesData:** Image metadata and storage information - -#### **Cross-Cutting Concerns** - -**Exception Handling Layer** (`exception/`) -- **ExceptionController:** REST endpoint for error responses -- **ExceptionHandlerFilter:** Global exception handling filter -- **StoneInscriptionException:** Custom exception type -- Provides unified error response format - -**Configuration Layer** (`configuration/`) -- **StoneinscriptionConfiguration:** Main security and application configuration - - Security filter chain setup - - OAuth2 provider configuration (Google, Facebook, Apple) - - CORS configuration - - Password encoding setup - - MongoDB Auditing activation - -**Utility Layer** (`util/`) -- Common utility functions -- Helper methods for complex operations - -### 1.1.3 External Integrations - -**OAuth2 Providers:** -1. **Google OAuth2** - - Authorization URI: `https://accounts.google.com/o/oauth2/auth` - - Token URI: `https://oauth2.googleapis.com/token` - - User Info: `https://openidconnect.googleapis.com/v1/userinfo` - -2. **Facebook OAuth2** - - Authorization URI: `https://www.facebook.com/v18.0/dialog/oauth` - - Token URI: `https://graph.facebook.com/v18.0/oauth/access_token` - - User Info: `https://graph.facebook.com/v18.0/me` - -3. **Apple OAuth2** - - Authorization URI: `https://appleid.apple.com/auth/authorize` - - Token URI: `https://appleid.apple.com/auth/token` - -**Monitoring & Observability:** -- **Prometheus Metrics:** Application metrics exposed at `/actuator/prometheus` -- **Health Checks:** `/actuator/health` -- **App Info:** `/actuator/info` -- **HikariCP Pool Monitoring:** Database connection pool metrics - -### 1.1.4 Request Processing Flow - -``` -HTTP Request - ↓ -CORS Filter - ↓ -Exception Handler Filter - ↓ -JWT Request Filter (Validation) - ↓ -Security Filter Chain - ↓ -Controller (endpoint handling) - ↓ -Service Layer (business logic) - ↓ -Repository Layer (data access) - ↓ -MongoDB (persistence) - ↓ -Response Processing & Return -``` - ---- - -## 1.2 Audit - -### 1.2.1 MongoDB Auditing Configuration - -The system implements comprehensive audit trails using Spring Data MongoDB's `@EnableMongoAuditing` feature: - -```java -@EnableMongoAuditing -``` - -**Audit Capabilities:** -- Automatic timestamp tracking for entity creation (`@CreatedDate`) -- Automatic timestamp tracking for entity modifications (`@LastModifiedDate`) -- User tracking for creation (`@CreatedBy`) -- User tracking for modifications (`@LastModifiedBy`) - -### 1.2.2 Auditable Entities - -**User Entity** -- Creation timestamp -- Last modification timestamp -- Created by username -- Last modified by username - -**InscriptionPost Entity** -- Creation timestamp -- Last modification timestamp -- Created by user -- Last modified by user -- Post deletion audit trail - -**PublicPostDescription Entity** -- Version tracking -- Description history -- Created by user -- Modification history - -**UserImage Entity** -- Image upload timestamp -- Image modification timestamp -- User association - -### 1.2.3 Activity Tracking - -**User Activity Logging** -- Endpoint: `POST /oauth2/authenticated/active` -- Tracks: Last active timestamp for each user session -- Purpose: Monitor user engagement and session validity - -### 1.2.4 Post Lifecycle Tracking - -**Post Creation Audit Trail** -- Timestamp of post creation -- Creator (user ID/username) -- Initial post content - -**Post Modification Audit Trail** -- Timestamp of modification -- Modified by (user ID/username) -- Description version history - -**Post Deletion Audit Trail** -- Timestamp of deletion -- Deleted by (user ID/username) -- Soft/hard delete tracking - -### 1.2.5 Image Upload Auditing - -**Upload Events** -- User profile image uploads tracked -- Cover image uploads tracked -- Timestamp and user association - -### 1.2.6 Authentication Auditing - -**OAuth2 Login Events** -- Provider (Google/Facebook/Apple) -- Timestamp of login -- User information captured -- Token generation records - -**JWT Token Usage** -- Token generation timestamp -- Token expiration -- Token refresh events - ---- - -## 1.3 Security - -### 1.3.1 Authentication Architecture - -#### **Multi-Provider OAuth2 Authentication** - -The system implements a sophisticated OAuth2 authentication mechanism supporting three major providers: - -``` -User - ↓ -Browser/Client - ↓ -OAuth2 Provider (Google/Facebook/Apple) - ↓ -Authorization Code Flow - ↓ -CustomOAuth2SuccessHandler - ↓ -JWT Token Generation - ↓ -Application -``` - -**OAuth2 Flow:** -1. User initiates login via OAuth2 provider -2. User authenticates with provider (Google, Facebook, or Apple) -3. Authorization code returned to application -4. Application exchanges code for access token -5. Application retrieves user information -6. `CustomOAuth2SuccessHandler` processes successful authentication -7. JWT token generated for stateless API authentication - -#### **JWT (JSON Web Token) Implementation** - -**JWT Components:** -- **Algorithm:** HS256 (HMAC with SHA-256) -- **Key Generation:** 256-bit secure random key using `SecureRandom` -- **Key Encoding:** Base64 encoded for transmission -- **Signing:** Using Nimbus JOSE library - -**JWT Structure:** -``` -Header.Payload.Signature - -Header: - { - "alg": "HS256", - "typ": "JWT" - } - -Payload (Claims): - { - "sub": "user-id", - "username": "username", - "iat": 1234567890, - "exp": 1234571490, - "authorities": ["ROLE_USER"], - "provider": "GOOGLE|FACEBOOK|APPLE" - } - -Signature: HMAC-SHA256(base64(header) + "." + base64(payload), secret) -``` - -**Token Lifecycle:** -- **Generation:** During successful OAuth2 authentication -- **Refresh:** Using `/oauth2/authenticated/refresh-token` endpoint -- **Validation:** On each API request via `JwtRequestFilter` -- **Expiration:** Based on configured TTL - -### 1.3.2 Authorization & Access Control - -#### **Method-Level Security** - -The configuration enables three levels of authorization: - -```java -@EnableMethodSecurity( - prePostEnabled = true, // @PreAuthorize, @PostAuthorize - securedEnabled = true, // @Secured - jsr250Enabled = true // @RolesAllowed -) -``` - -**Security Annotations Used:** -- `@PreAuthorize`: Pre-execution authorization checks -- `@PostAuthorize`: Post-execution authorization checks -- `@Secured`: Role-based access control -- `@RolesAllowed`: JSR-250 role annotations - -#### **Request-Level Security** - -```java -SecurityFilterChain: - - CORS enabled - - CSRF disabled (stateless JWT authentication) - - Session policy: STATELESS - - Authorization rules: - * Public endpoints: /login/**, /post/public/**, /user/public/** - * Authenticated required: /oauth2/authenticated/**, /user/**, /post/** - * Admin endpoints: (configurable via annotations) -``` - -### 1.3.3 Session Management - -**Session Creation Policy: STATELESS** -- No server-side session storage -- Each request contains JWT token -- Reduces server memory footprint -- Improves scalability and distributed deployment support - -**CSRF Protection:** -- Disabled (appropriate for stateless JWT authentication) -- CSRF tokens not required for API requests - -### 1.3.4 Password Security - -**Password Encoding:** -- Algorithm: BCrypt (industry standard) -- Cost factor: 12 (default Spring Security) -- Automatically hashes passwords -- Prevents password plaintext storage - -```java -@Bean -public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); -} -``` - -### 1.3.5 CORS (Cross-Origin Resource Sharing) - -**Configuration:** -- Configurable origin URL via `app.cors.url` property -- Supports: - - Allowed origins - - Allowed HTTP methods - - Allowed headers - - Credentials support - - Max age for preflight cache - -**Purpose:** -- Prevents unauthorized cross-domain requests -- Protects against CORS-based attacks - -### 1.3.6 Data Validation & Input Sanitization - -**Exception Handling Filter:** -- Captures and validates all exceptions -- Returns sanitized error messages -- Prevents information leakage -- Implements custom exception handling - -### 1.3.7 API Endpoint Security - -#### **Public Endpoints:** -``` -GET /post/public/images/{id} - Retrieve post images -GET /user/public/images/{id} - Retrieve user images -POST /oauth2/login/** - OAuth2 login initiation -``` - -#### **Authenticated Endpoints:** -``` -POST /oauth2/authenticated/refresh-token - JWT refresh -POST /oauth2/authenticated/active - Activity tracking -POST /oauth2/logout - Logout - -GET /user/profile - User profile retrieval -POST /user/updateProfile - Profile update -POST /user/uploadProfileImage - Profile image upload -POST /user/uploadCoverImage - Cover image upload - -POST /post/addPostWithFile - Create post -POST /post/getAllPost - List all posts -POST /post/getAllUserPost - List user posts -POST /post/postDelete - Delete post -POST /post/addPoastDiscription - Add description -POST /post/getPostDiscription - Get description -POST /post/updatePostDiscription - Update description -POST /post/discriptionDelete - Delete description -POST /post/addRating - Rate post -POST /post/addVote - Vote on description -``` - -### 1.3.8 Security Headers & Filters - -**Security Filter Chain:** -1. **ExceptionHandlerFilter:** Catches and handles security exceptions -2. **JwtRequestFilter:** Validates JWT tokens -3. **JwtAuthenticationEntryPoint:** Handles authentication failures - -### 1.3.9 Third-Party Secret Management - -**Environment Variables (Configuration):** -``` -GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET -FACEBOOK_CLIENT_ID -FACEBOOK_CLIENT_SECRET -APPLE_CLIENT_ID -APPLE_CLIENT_SECRET -DATABASE_URL -JWT_SECRET_KEY -``` - -**Storage:** `.env` file loaded via Spring configuration (excluded from version control) - -### 1.3.10 Security Best Practices - -| Practice | Implementation | -|----------|-----------------| -| **Stateless Authentication** | JWT tokens instead of server-side sessions | -| **Strong Encryption** | BCrypt for passwords, HS256 for JWT signing | -| **Secure Defaults** | HTTPS-ready, CSRF enabled where applicable | -| **Input Validation** | Request validation via filters and controllers | -| **Error Handling** | Sanitized error messages without stack traces | -| **CORS Protection** | Configurable allowed origins | -| **Secret Management** | Environment variables for sensitive data | -| **Audit Trails** | MongoDB auditing on all entities | -| **Session Timeout** | JWT expiration built-in | -| **Rate Limiting** | (Configurable via properties) | - ---- - -## 1.4 Components Interface Design - -### 1.4.1 API Contract Specifications - -#### **Authentication Endpoints** - -**1. OAuth2 Login Initiation** -``` -Endpoint: POST /oauth2/authorize/{provider} -Provider: google | facebook | apple -Response: Redirect to OAuth2 provider authorization URL -``` - -**2. OAuth2 Callback with Authorization Code** -``` -Endpoint: POST /oauth2/code/{registrationId} -Parameters: - - code: OAuth2 authorization code - - state: CSRF protection state -Response: - { - "accessToken": "eyJhbGciOiJIUzI1NiIs...", - "refreshToken": "refresh_token_value", - "tokenType": "Bearer", - "expiresIn": 3600, - "user": { - "id": "user-id", - "username": "username", - "email": "user@example.com", - "provider": "GOOGLE" - } - } -``` - -**3. Token Refresh** -``` -Endpoint: POST /oauth2/authenticated/refresh-token -Headers: Authorization: Bearer {refreshToken} -Response: - { - "accessToken": "new_jwt_token", - "expiresIn": 3600 - } -``` - -**4. User Activity Update** -``` -Endpoint: POST /oauth2/authenticated/active -Headers: Authorization: Bearer {accessToken} -Response: - { - "lastActive": "2024-04-11T10:30:00Z", - "status": "success" - } -``` - -**5. Logout** -``` -Endpoint: POST /oauth2/logout -Headers: Authorization: Bearer {accessToken} -Response: - { - "message": "Logout successful", - "status": "success" - } -``` - -#### **User Profile Endpoints** - -**1. Get User Profile** -``` -Endpoint: GET /user/profile -Headers: Authorization: Bearer {accessToken} -Response: - { - "id": "user-id", - "username": "john_doe", - "email": "john@example.com", - "profileImageId": "image-id-1", - "coverImageId": "image-id-2", - "createdAt": "2024-01-15T00:00:00Z", - "lastModifiedAt": "2024-04-10T00:00:00Z" - } -``` - -**2. Update User Profile** -``` -Endpoint: POST /user/updateProfile -Headers: Authorization: Bearer {accessToken} -Content-Type: application/json -Body: - { - "username": "new_username", - "bio": "User bio", - "location": "City, Country" - } -Response: - { - "id": "user-id", - "username": "new_username", - "message": "Profile updated successfully" - } -``` - -**3. Upload Profile Image** -``` -Endpoint: POST /user/uploadProfileImage -Headers: Authorization: Bearer {accessToken} -Content-Type: multipart/form-data -Body: - { - "file": - } -Response: - { - "imageId": "image-id-1", - "fileName": "profile.jpg", - "uploadedAt": "2024-04-11T10:30:00Z", - "url": "/user/public/images/image-id-1" - } -``` - -**4. Upload Cover Image** -``` -Endpoint: POST /user/uploadCoverImage -Headers: Authorization: Bearer {accessToken} -Content-Type: multipart/form-data -Body: - { - "file": - } -Response: - { - "imageId": "image-id-2", - "fileName": "cover.jpg", - "uploadedAt": "2024-04-11T10:30:00Z", - "url": "/user/public/images/image-id-2" - } -``` - -**5. Get User Images (Public)** -``` -Endpoint: GET /user/public/images/{id} -Response: -Headers: Content-Type: image/jpeg | image/png | image/webp -``` - -#### **Post Management Endpoints** - -**1. Create Post with Files** -``` -Endpoint: POST /post/addPostWithFile -Headers: Authorization: Bearer {accessToken} -Content-Type: multipart/form-data -Body: - { - "title": "Post title", - "content": "Post content description", - "location": "Location information", - "files": [, ], - "tags": ["tag1", "tag2"] - } -Response: - { - "postId": "post-id-1", - "title": "Post title", - "createdBy": "user-id", - "createdAt": "2024-04-11T10:30:00Z", - "imageCount": 2, - "images": [ - { - "imageId": "img-1", - "url": "/post/public/images/img-1" - } - ] - } -``` - -**2. Get All Posts** -``` -Endpoint: POST /post/getAllPost -Headers: Authorization: Bearer {accessToken} -Query: - { - "page": 1, - "limit": 20, - "sortBy": "createdAt", - "order": "desc" - } -Response: - { - "totalPosts": 150, - "page": 1, - "limit": 20, - "posts": [ - { - "postId": "post-id-1", - "title": "Post title", - "content": "Post content", - "createdBy": "user-id", - "createdAt": "2024-04-11T10:30:00Z", - "ratingCount": 5, - "averageRating": 4.5 - } - ] - } -``` - -**3. Get User Posts** -``` -Endpoint: POST /post/getAllUserPost -Headers: Authorization: Bearer {accessToken} -Query: - { - "userId": "user-id", - "page": 1, - "limit": 20 - } -Response: - { - "totalUserPosts": 25, - "posts": [...] - } -``` - -**4. Delete Post** -``` -Endpoint: POST /post/postDelete -Headers: Authorization: Bearer {accessToken} -Body: - { - "postId": "post-id-1" - } -Response: - { - "postId": "post-id-1", - "message": "Post deleted successfully", - "deletedAt": "2024-04-11T10:30:00Z" - } -``` - -**5. Get Post Images (Public)** -``` -Endpoint: GET /post/public/images/{id} -Response: -Headers: Content-Type: image/jpeg | image/png | image/webp -``` - -#### **Post Description Endpoints** - -**1. Add Post Description** -``` -Endpoint: POST /post/addPoastDiscription -Headers: Authorization: Bearer {accessToken} -Body: - { - "postId": "post-id-1", - "description": "Detailed description of the artifact", - "language": "en" - } -Response: - { - "descriptionId": "desc-id-1", - "postId": "post-id-1", - "description": "Detailed description...", - "version": 1, - "createdAt": "2024-04-11T10:30:00Z" - } -``` - -**2. Get Post Description** -``` -Endpoint: POST /post/getPostDiscription -Headers: Authorization: Bearer {accessToken} -Body: - { - "postId": "post-id-1" - } -Response: - { - "descriptionId": "desc-id-1", - "postId": "post-id-1", - "description": "Detailed description...", - "version": 1, - "createdBy": "user-id", - "createdAt": "2024-04-11T10:30:00Z" - } -``` - -**3. Update Post Description** -``` -Endpoint: POST /post/updatePostDiscription -Headers: Authorization: Bearer {accessToken} -Body: - { - "descriptionId": "desc-id-1", - "description": "Updated description", - "version": 2 - } -Response: - { - "descriptionId": "desc-id-1", - "description": "Updated description", - "version": 2, - "updatedAt": "2024-04-11T10:35:00Z" - } -``` - -**4. Delete Post Description** -``` -Endpoint: POST /post/discriptionDelete -Headers: Authorization: Bearer {accessToken} -Body: - { - "descriptionId": "desc-id-1" - } -Response: - { - "descriptionId": "desc-id-1", - "message": "Description deleted successfully", - "deletedAt": "2024-04-11T10:30:00Z" - } -``` - -#### **Post Interactions Endpoints** - -**1. Add Rating to Post** -``` -Endpoint: POST /post/addRating -Headers: Authorization: Bearer {accessToken} -Body: - { - "postId": "post-id-1", - "rating": 5, - "comment": "Excellent artifact" - } -Response: - { - "ratingId": "rating-id-1", - "postId": "post-id-1", - "rating": 5, - "ratedBy": "user-id", - "createdAt": "2024-04-11T10:30:00Z" - } -``` - -**2. Add Vote to Description** -``` -Endpoint: POST /post/addVote -Headers: Authorization: Bearer {accessToken} -Body: - { - "descriptionId": "desc-id-1", - "voteType": "upvote|downvote" - } -Response: - { - "voteId": "vote-id-1", - "descriptionId": "desc-id-1", - "voteType": "upvote", - "votedBy": "user-id", - "createdAt": "2024-04-11T10:30:00Z" - } -``` - -#### **Error Response Format** - -**Standard Error Response:** -```json -{ - "timestamp": "2024-04-11T10:30:00Z", - "status": 400, - "error": "Bad Request", - "message": "Validation failed", - "details": { - "field": "email", - "message": "Invalid email format" - }, - "path": "/user/updateProfile" -} -``` - -**Authentication Error (401):** -```json -{ - "timestamp": "2024-04-11T10:30:00Z", - "status": 401, - "error": "Unauthorized", - "message": "Invalid or expired JWT token", - "path": "/post/getAllPost" -} -``` - -**Authorization Error (403):** -```json -{ - "timestamp": "2024-04-11T10:30:00Z", - "status": 403, - "error": "Forbidden", - "message": "Access denied. You do not have permission to delete this post.", - "path": "/post/postDelete" -} -``` - -### 1.4.2 Data Model Interfaces - -#### **User Entity** -```typescript -interface User { - id: string; - username: string; - email: string; - provider: "GOOGLE" | "FACEBOOK" | "APPLE"; - profileImageId?: string; - coverImageId?: string; - bio?: string; - location?: string; - createdAt: Date; - createdBy?: string; - lastModifiedAt?: Date; - lastModifiedBy?: string; -} -``` - -#### **InscriptionPost Entity** -```typescript -interface InscriptionPost { - postId: string; - title: string; - content: string; - location: string; - createdBy: string; - createdAt: Date; - lastModifiedAt: Date; - lastModifiedBy?: string; - images: ImageReference[]; - descriptionId?: string; - ratings: Rating[]; - visibility: "PUBLIC" | "PRIVATE" | "RESTRICTED"; - tags: string[]; -} -``` - -#### **PublicPostDescription Entity** -```typescript -interface PublicPostDescription { - descriptionId: string; - postId: string; - description: string; - version: number; - language: string; - createdBy: string; - createdAt: Date; - lastModifiedAt: Date; - votes: { - upvote: number; - downvote: number; - }; -} -``` - -#### **UserImage Entity** -```typescript -interface UserImage { - imageId: string; - userId: string; - imageType: "PROFILE" | "COVER"; - fileName: string; - fileSize: number; - mimeType: string; - uploadedAt: Date; - url: string; - metadata?: { - width?: number; - height?: number; - format?: string; - }; -} -``` - -### 1.4.3 Service Layer Interfaces - -#### **User Service Interface** -```java -interface IUserService { - User getUserProfile(String userId); - User updateUserProfile(String userId, UserUpdateRequest request); - UserImage uploadProfileImage(String userId, MultipartFile file); - UserImage uploadCoverImage(String userId, MultipartFile file); - UserImage getUserImage(String imageId); - void deleteUser(String userId); -} -``` - -#### **Post Service Interface** -```java -interface IPostService { - InscriptionPost createPost(PostCreateRequest request, String userId); - List getAllPosts(Pageable pageable); - List getUserPosts(String userId, Pageable pageable); - InscriptionPost getPostById(String postId); - void deletePost(String postId, String userId); - Rating addRating(String postId, RatingRequest request, String userId); - Vote addVote(String descriptionId, VoteRequest request, String userId); -} -``` - -#### **Description Service Interface** -```java -interface IDescriptionService { - PublicPostDescription addDescription(String postId, DescriptionRequest request, String userId); - PublicPostDescription getDescription(String postId); - PublicPostDescription updateDescription(String descriptionId, DescriptionRequest request, String userId); - void deleteDescription(String descriptionId, String userId); -} -``` - -### 1.4.4 Authentication Service Interface - -```java -interface IAuthenticationService { - JwtTokenResponse authenticateViaOAuth2(String provider, String authorizationCode); - JwtTokenResponse refreshAccessToken(String refreshToken); - void logout(String userId); - UserDetails loadUserByUsername(String username); - boolean validateJwtToken(String token); - String extractUserIdFromToken(String token); -} -``` - -### 1.4.5 Communication Protocol - -**Protocol:** REST/HTTP(S) -- **Secure Transport:** HTTPS (TLS 1.2+) -- **Request Format:** JSON (application/json) -- **Response Format:** JSON (application/json) -- **File Uploads:** Multipart Form Data (multipart/form-data) -- **Default Encoding:** UTF-8 - -### 1.4.6 Component Dependency Graph - -``` -┌─────────────────────────────────────────────────────────┐ -│ Controllers │ -│ (UserController, PostController) │ -└────────────────────┬────────────────────────────────────┘ - │ - ┌────────────┴────────────┐ - ↓ ↓ -┌────────────────┐ ┌──────────────────┐ -│ UserService │ │ PostService │ -│ (Business │ │ (Business Logic) │ -│ Logic) │ │ │ -└────┬───────────┘ └──────┬───────────┘ - │ │ - ├─────────────┬─────────────┤ - ↓ ↓ ↓ -┌──────────┐ ┌──────────┐ ┌──────────────┐ -│UserRepo │ │PostRepo │ │DescRepro │ -│(Data │ │(Data │ │(Data Access) │ -│Access) │ │Access) │ │ │ -└──────┬───┘ └──────┬───┘ └────────┬─────┘ - │ │ │ - └─────────────┴──────────────┴─────┐ - ↓ - ┌────────────┐ - │ MongoDB │ - │ Database │ - └────────────┘ -``` - ---- - -## Conclusion - -This system architecture provides a robust, scalable, and secure foundation for the Artifact Registry Backend. The multi-layered approach with clear separation of concerns, comprehensive security controls, audit trails, and well-defined component interfaces ensures maintainability, reliability, and compliance with modern application development standards. - -**Key Strengths:** -- ✓ OAuth2 multi-provider authentication -- ✓ JWT stateless API authentication -- ✓ Comprehensive audit trails -- ✓ Strong security controls -- ✓ MongoDB auditing built-in -- ✓ Clear component interfaces -- ✓ Scalable microservices-ready design -- ✓ Full observability with Prometheus monitoring - ---- - -**Document Version:** 1.0 -**Last Updated:** 2024-04-11 -**Status:** Complete diff --git a/Test.MD b/Test.MD deleted file mode 100644 index 9fd1907..0000000 --- a/Test.MD +++ /dev/null @@ -1,180 +0,0 @@ -# Test Documentation for Stone Inscription Application - -This document outlines the testing strategy, planned tests, and results for the Stone Inscription application to ensure it is production-ready. As tests are currently not implemented, this serves as a template for documenting test cases, execution, and outcomes. - -## Test Strategy - -The testing approach follows a comprehensive strategy to cover unit, integration, API, security, and performance aspects. Tests will be automated where possible using JUnit, Mockito, and Spring Boot Test framework. Manual testing will be conducted for UI and exploratory scenarios. - -- **Unit Tests**: Test individual components in isolation. -- **Integration Tests**: Test interactions between components. -- **API Tests**: Validate REST endpoints functionality. -- **Security Tests**: Ensure authentication, authorization, and data protection. -- **Performance Tests**: Verify system performance under load. -- **Production Readiness Criteria**: All critical tests must pass with 80%+ code coverage, no high-severity security vulnerabilities, and acceptable performance metrics. - -## Unit Tests - -Unit tests focus on individual classes and methods. - -### Planned Tests -- **UserService Tests**: - - Test user profile retrieval. - - Test profile update with valid/invalid data. - - Test image upload validation. -- **PostService Tests**: - - Test post creation with files. - - Test post retrieval and deletion. - - Test description and rating operations. -- **JwtUtil Tests**: - - Test token generation and validation. - - Test token refresh logic. -- **Exception Handling Tests**: - - Test custom exception responses. - -### Test Results -| Test Case | Status | Pass/Fail | Notes | -|-----------|--------|-----------|-------| -| UserService - Profile Retrieval | Not Executed | - | - | -| UserService - Profile Update | Not Executed | - | - | -| UserService - Image Upload | Not Executed | - | - | -| PostService - Post Creation | Not Executed | - | - | -| PostService - Post Retrieval | Not Executed | - | - | -| JwtUtil - Token Generation | Not Executed | - | - | -| Exception Handling | Not Executed | - | - | - -## Integration Tests - -Integration tests verify component interactions, such as service-to-repository and controller-to-service. - -### Planned Tests -- **User Registration and Login Flow**: - - Test OAuth2 login and JWT issuance. - - Test logout and token invalidation. -- **Post Creation and Management**: - - Test full post creation with file upload. - - Test user-specific post retrieval. -- **Database Interactions**: - - Test CRUD operations on User, Post, and related entities. - -### Test Results -| Test Case | Status | Pass/Fail | Notes | -|-----------|--------|-----------|-------| -| OAuth2 Login Flow | Not Executed | - | - | -| Post Creation Flow | Not Executed | - | - | -| Database CRUD Operations | Not Executed | - | - | - -## API Tests - -API tests validate REST endpoints using tools like Postman or RestAssured. - -### Planned Tests -- **Authentication Endpoints**: - - POST /oauth2/logout - - POST /oauth2/authenticated/refresh-token - - POST /oauth2/authenticated/active -- **User Endpoints**: - - GET /user/profile - - POST /user/updateProfile - - POST /user/uploadProfileImage - - POST /user/uploadCoverImage - - GET /user/public/images/{id} -- **Post Endpoints**: - - POST /post/addPostWithFile - - POST /post/getAllPost - - GET /post/public/images/{id} - - POST /post/getAllUserPost - - POST /post/addPoastDiscription - - POST /post/getPostDiscription - - POST /post/updatePostDiscription - - POST /post/addRating - - POST /post/addVote - - POST /post/userProfile - - POST /post/postDelete - - POST /post/discriptionDelete - -### Test Results -| Endpoint | Method | Status | Pass/Fail | Response Time | Notes | -|----------|--------|--------|-----------|---------------|-------| -| /oauth2/logout | POST | Not Tested | - | - | - | -| /oauth2/authenticated/refresh-token | POST | Not Tested | - | - | - | -| /oauth2/authenticated/active | POST | Not Tested | - | - | - | -| /user/profile | GET | Not Tested | - | - | - | -| /user/updateProfile | POST | Not Tested | - | - | - | -| /user/uploadProfileImage | POST | Not Tested | - | - | - | -| /user/uploadCoverImage | POST | Not Tested | - | - | - | -| /user/public/images/{id} | GET | Not Tested | - | - | - | -| /post/addPostWithFile | POST | Not Tested | - | - | - | -| /post/getAllPost | POST | Not Tested | - | - | - | -| /post/public/images/{id} | GET | Not Tested | - | - | - | -| /post/getAllUserPost | POST | Not Tested | - | - | - | -| /post/addPoastDiscription | POST | Not Tested | - | - | - | -| /post/getPostDiscription | POST | Not Tested | - | - | - | -| /post/updatePostDiscription | POST | Not Tested | - | - | - | -| /post/addRating | POST | Not Tested | - | - | - | -| /post/addVote | POST | Not Tested | - | - | - | -| /post/userProfile | POST | Not Tested | - | - | - | -| /post/postDelete | POST | Not Tested | - | - | - | -| /post/discriptionDelete | POST | Not Tested | - | - | - | - -## Security Tests - -Security tests ensure the application is protected against common vulnerabilities. - -### Planned Tests -- **Authentication**: - - Test unauthorized access to secured endpoints. - - Test invalid token handling. -- **Authorization**: - - Test role-based access control. -- **Input Validation**: - - Test SQL injection prevention. - - Test XSS protection. -- **OAuth2 Security**: - - Test secure token storage and transmission. - -### Test Results -| Test Case | Status | Pass/Fail | Vulnerability Level | Notes | -|-----------|--------|-----------|---------------------|-------| -| Unauthorized Access | Not Executed | - | - | - | -| Invalid Token Handling | Not Executed | - | - | - | -| Role-Based Access | Not Executed | - | - | - | -| Input Validation | Not Executed | - | - | - | -| OAuth2 Security | Not Executed | - | - | - | - -## Performance Tests - -Performance tests evaluate system responsiveness and scalability. - -### Planned Tests -- **Load Testing**: - - Simulate concurrent users accessing endpoints. -- **Stress Testing**: - - Test system limits with high load. -- **Response Time Testing**: - - Measure API response times under normal load. - -### Test Results -| Test Case | Status | Pass/Fail | Avg Response Time | Throughput | Notes | -|-----------|--------|-----------|-------------------|------------|-------| -| Load Test - 100 Users | Not Executed | - | - | - | - | -| Stress Test - Peak Load | Not Executed | - | - | - | - | -| Response Time - Normal Load | Not Executed | - | - | - | - | - -## Test Results Summary - -- **Total Tests Planned**: [Count] -- **Tests Executed**: 0 -- **Tests Passed**: 0 -- **Tests Failed**: 0 -- **Code Coverage**: Not Measured -- **Production Readiness**: Not Ready (Tests not executed) - -### Recommendations -1. Implement unit and integration tests using JUnit and Mockito. -2. Use tools like JMeter for performance testing. -3. Conduct security audits with OWASP ZAP. -4. Automate API tests with RestAssured. -5. Update this document with actual results after test execution. - -Date: 11 April 2026 \ No newline at end of file From 3d8a5930f52d2f01c1f90b0da4aff2238fd00b3a Mon Sep 17 00:00:00 2001 From: nayan458 Date: Thu, 7 May 2026 14:36:19 +0530 Subject: [PATCH 28/28] updated the docker hub image registery --- .github/workflows/develop-deploy.yml | 4 ++-- .github/workflows/release.yml | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/develop-deploy.yml b/.github/workflows/develop-deploy.yml index a8a5ed6..c96886e 100644 --- a/.github/workflows/develop-deploy.yml +++ b/.github/workflows/develop-deploy.yml @@ -42,5 +42,5 @@ jobs: - name: Push Docker Images run: | - docker push nayanmoni458/stoneinscription:dev-latest - docker push nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} \ No newline at end of file + docker push nayanmoni458/stoneinscription-backend:dev-latest + docker push nayanmoni458/stoneinscription-backend:sha-${GITHUB_SHA::7} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 493083d..a1be56a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,9 +31,9 @@ jobs: - name: Build Docker Image run: | docker build \ - -t nayanmoni458/stoneinscription:${GITHUB_REF_NAME} \ - -t nayanmoni458/stoneinscription:latest \ - -t nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} . + -t nayanmoni458/stoneinscription-backend:${GITHUB_REF_NAME} \ + -t nayanmoni458/stoneinscription-backend:latest \ + -t nayanmoni458/stoneinscription-backend:sha-${GITHUB_SHA::7} . - name: Login To Docker Hub uses: docker/login-action@v3 @@ -43,6 +43,6 @@ jobs: - name: Push Docker Images run: | - docker push nayanmoni458/stoneinscription:${GITHUB_REF_NAME} - docker push nayanmoni458/stoneinscription:latest - docker push nayanmoni458/stoneinscription:sha-${GITHUB_SHA::7} \ No newline at end of file + docker push nayanmoni458/stoneinscription-backend:${GITHUB_REF_NAME} + docker push nayanmoni458/stoneinscription-backend:latest + docker push nayanmoni458/stoneinscription-backend:sha-${GITHUB_SHA::7} \ No newline at end of file