Skip to content

Spec-driven: article reports & moderation workflow#568

Open
devin-ai-integration[bot] wants to merge 2 commits intomasterfrom
devin/spec-driven-moderation
Open

Spec-driven: article reports & moderation workflow#568
devin-ai-integration[bot] wants to merge 2 commits intomasterfrom
devin/spec-driven-moderation

Conversation

@devin-ai-integration
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot commented Apr 22, 2026

Summary

Implements the article reports & moderation loop (report → queue → resolve) end-to-end per spec. Any authenticated user can file a report against an article; ADMIN users can list pending reports and resolve them as UPHELD (soft-deletes the article) or DISMISSED.

Spec → Implementation mapping

Spec § Requirement Files
§4.1 article_reports table + unique (article_id, reporter_id) + index (status, created_at) <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/resources/db/migration/V2__article_reports_and_moderation.sql" />
§4.2 users.role (USER/ADMIN) + seed admin user V2 migration, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/core/user/User.java" />, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/resources/mapper/UserMapper.xml" />
§4.3 articles.deleted_at V2 migration, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/core/article/Article.java" />, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/resources/mapper/ArticleMapper.xml" />
§5.1 POST /articles/{slug}/reports <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/api/ArticleReportsApi.java" /> + <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/application/article/ArticleReportCommandService.java" />
§5.2 GET /admin/reports?status=&limit=&offset= <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/api/AdminReportsApi.java" /> + <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/application/article/ArticleReportQueryService.java" /> + <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/resources/mapper/ArticleReportReadService.xml" />
§5.3 POST /admin/reports/{id}/resolve (UPHELD soft-deletes, DISMISSED does not; idempotency guard) AdminReportsApi + <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/application/article/ReportResolutionService.java" />
§5.4 deleted_at IS NULL filter on every article read endpoint (list, feed, by-slug, cursor paths, author feed, counts) <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/resources/mapper/ArticleReadService.xml" />, ArticleMapper.xml
§6.1 Report response shape <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/application/data/ArticleReportData.java" />
§7 @PreAuthorize("hasRole('ADMIN')") on admin controller + service-layer isAdmin() check; /admin/** + /articles/*/reports secured <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/api/security/WebSecurityConfig.java" />, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/main/java/io/spring/api/security/JwtTokenFilter.java" />
§8.1 Service tests (duplicate pending, UPHELD vs DISMISSED) <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/test/java/io/spring/application/article/ArticleReportServiceTest.java" />, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/test/java/io/spring/application/article/ReportResolutionServiceTest.java" />
§8.2 API tests (201/422/401 on report; 403/list/UPHELD/DISMISSED on admin) <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/test/java/io/spring/api/ArticleReportsApiTest.java" />, <ref_file file="/home/ubuntu/repos/spring-boot-realworld-example-app/src/test/java/io/spring/api/AdminReportsApiTest.java" />

./gradlew spotlessApply test is green locally (all 92 tests pass).

Out of scope (per spec §3 / §10)

  • Rate limiting on POST /reports
  • Multi-strike auto-takedown (e.g. N upheld reports → auto-ban)
  • GraphQL surface for moderation
  • Email/notification delivery; appeals workflow; admin UI

Review & Testing Checklist for Human

  • Flyway migration (V2__article_reports_and_moderation.sql) — verify it runs cleanly against an empty schema on SQLite and that the seed admin bcrypt hash is acceptable. Deleting dev.db and running ./gradlew bootRun is the quickest check.
  • Soft-delete filter coverage in ArticleReadService.xml — I added A.deleted_at IS NULL to every read path I could find (list, feed, by-slug, cursor-based findArticlesWithCursor/findArticlesOfAuthorsWithCursor, countFeedSize, countArticle). Please grep the XML and confirm nothing is missed. Also confirm GET /articles/{slug}/comments returns 404 on a soft-deleted article (this works by piggybacking on findBySlug filtering — there's no dedicated test for it).
  • End-to-end UPHELD flow against a real DB: file a report → list it as admin → resolve UPHELD → confirm GET /articles/{slug} now returns 404 and the article disappears from GET /articles. The two sides (service marks status, MyBatis softDeleteArticle sets deleted_at, read XML filters) are tested independently but not threaded together in a single integration test.
  • Admin admin-queue SQL (ArticleReportReadService.xml) — LEFT JOINs users twice (reporter + moderator) and joins articles for the slug. Eyeball the result-map aliasing to make sure reporter vs. moderator columns don't alias-collide at runtime.
  • JWT authority wiringJwtTokenFilter now emits ROLE_USER / ROLE_ADMIN authorities based on user.isAdmin(). Combined with @EnableGlobalMethodSecurity(prePostEnabled=true) and hasRole("ADMIN") in both WebSecurityConfig and @PreAuthorize, confirm a USER gets 403 (not 401) on /admin/** and an unauthenticated request gets 401.

Notes

  • Request bodies rely on the project's existing spring.jackson.deserialization.UNWRAP_ROOT_VALUE=true + @JsonRootName("report") / @JsonRootName("resolution") to accept {"report": {...}} and {"resolution": {...}}, matching the existing NewArticleParam convention.
  • Error envelopes use MapBindingResult (not BeanPropertyBindingResult) because the rejected field names (reason, report, action, resolution) don't always correspond to bean properties on a concrete target; MapBindingResult tolerates arbitrary field keys and produces the same {"errors": {"<field>": ["..."]}} output via ErrorResourceSerializer.
  • Added /bin/ to .gitignore (Eclipse output dir) — unrelated hygiene.

Link to Devin session: https://app.devin.ai/sessions/619064b96aa14e2fbd1237208424485f
Requested by: @achalc


Open in Devin Review

Co-Authored-By: Achal Channarasappa <achal.channarasappa@cognition.ai>
@devin-ai-integration
Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

Addresses Devin Review: allows re-reporting after a prior report was resolved, matching spec §5.1 (422 only when an existing PENDING report exists).

Co-Authored-By: Achal Channarasappa <achal.channarasappa@cognition.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant