feat(moderation): report a message to moderators with a site-admin review queue (LC-334) #345

Merged
longjacksonle merged 1 commit from feat/lc-334-message-reports into main 2026-06-18 05:17:53 +02:00

What

Adds member-initiated message reporting with a site-admin review queue. A "Report" action in the message hover menu (shown for other members' non-system messages) opens a modal where the reporter picks a preset category (Spam / Harassment / Inappropriate / Other) and an optional note. Site admins triage open reports at a new /admin/reports queue: each row shows the category, reporter, note, room, author, a message excerpt, and a jump-to-message link, with Resolve and Dismiss actions. Enforcement (delete message, ban/mute author) keeps using the existing tools; this is the intake + triage layer.

How

  • New chat.db migration 0063_message_reports: UNIQUE(message_id, reporter_id) makes a re-report by the same user a no-op (insert is ON CONFLICT DO NOTHING); room_id is denormalized so the queue renders without a join and survives a soft-deleted message; FK to messages with ON DELETE CASCADE; index on (status, created_at).
  • db::reports (create / list_open / count_open / set_status) with a server-side category allowlist and a 500-char note cap. set_status only transitions reports still open, so two admins cannot double-handle one row.
  • Submission (GET/POST /messages/{id}/report) lives in a new routes::report module compiled in BOTH build modes; the table is harmless and forward-compatible to populate in either.
  • The review queue, resolve/dismiss handlers, the Reports nav link + open-count badge, and the live WS arm/renderer are #[cfg(feature = "standalone")], mirroring the existing admin-surface gating. The new AdminReportChanged event is broadcast on the existing admin topic; the WS send task re-queries and swaps #admin-reports-list and #admin-reports-badge OOB (id-keyed, self-limiting per LC-156), so filing / resolving / dismissing live-updates every admin tab. In saas the event never fires and falls through to render_event (None).
  • The queue list renders from a shared admin/reports_items.html partial used by both the full page and the OOB fragment, so a fresh load and a live update render identically. The jump link uses the canonical /m/{id} permalink (resolves room vs DM server-side).
  • The nav badge inner is fetched on load (hx-trigger="load") so the open count does not have to thread through every admin page struct.
  • i18n: report categories, modal, queue, and action strings added in en + es with parity.

Testing

  • just check (both feature sets compile; Askama validates the new templates).
  • Full just test (standalone) and just test-saas green: 0 failures in both. db_reports runs in both modes; routes_reports is file-scoped #![cfg(standalone)] since it exercises /admin/reports.
  • The 4 LC-77 synthetic-actor render fixtures gained two blank lines from the new report conditional (which is correctly false for webhook / email / bridge actors, whose user_id is empty) and were regenerated; no Report button leaks into synthetic-actor output.

No operator action: new feature, no env var / config / contract change. The migration auto-applies at startup.

🤖 Generated with Claude Code

## What Adds member-initiated message reporting with a site-admin review queue. A "Report" action in the message hover menu (shown for other members' non-system messages) opens a modal where the reporter picks a preset category (Spam / Harassment / Inappropriate / Other) and an optional note. Site admins triage open reports at a new `/admin/reports` queue: each row shows the category, reporter, note, room, author, a message excerpt, and a jump-to-message link, with Resolve and Dismiss actions. Enforcement (delete message, ban/mute author) keeps using the existing tools; this is the intake + triage layer. ## How - New `chat.db` migration `0063_message_reports`: `UNIQUE(message_id, reporter_id)` makes a re-report by the same user a no-op (insert is `ON CONFLICT DO NOTHING`); `room_id` is denormalized so the queue renders without a join and survives a soft-deleted message; FK to `messages` with `ON DELETE CASCADE`; index on `(status, created_at)`. - `db::reports` (create / list_open / count_open / set_status) with a server-side category allowlist and a 500-char note cap. `set_status` only transitions reports still `open`, so two admins cannot double-handle one row. - Submission (`GET`/`POST /messages/{id}/report`) lives in a new `routes::report` module compiled in BOTH build modes; the table is harmless and forward-compatible to populate in either. - The review queue, resolve/dismiss handlers, the `Reports` nav link + open-count badge, and the live WS arm/renderer are `#[cfg(feature = "standalone")]`, mirroring the existing admin-surface gating. The new `AdminReportChanged` event is broadcast on the existing `admin` topic; the WS send task re-queries and swaps `#admin-reports-list` and `#admin-reports-badge` OOB (id-keyed, self-limiting per LC-156), so filing / resolving / dismissing live-updates every admin tab. In saas the event never fires and falls through to `render_event` (None). - The queue list renders from a shared `admin/reports_items.html` partial used by both the full page and the OOB fragment, so a fresh load and a live update render identically. The jump link uses the canonical `/m/{id}` permalink (resolves room vs DM server-side). - The nav badge inner is fetched on load (`hx-trigger="load"`) so the open count does not have to thread through every admin page struct. - i18n: report categories, modal, queue, and action strings added in `en` + `es` with parity. ## Testing - `just check` (both feature sets compile; Askama validates the new templates). - Full `just test` (standalone) and `just test-saas` green: 0 failures in both. `db_reports` runs in both modes; `routes_reports` is file-scoped `#![cfg(standalone)]` since it exercises `/admin/reports`. - The 4 LC-77 synthetic-actor render fixtures gained two blank lines from the new report conditional (which is correctly false for webhook / email / bridge actors, whose `user_id` is empty) and were regenerated; no Report button leaks into synthetic-actor output. No operator action: new feature, no env var / config / contract change. The migration auto-applies at startup. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(moderation): report a message to moderators with a site-admin review queue
Some checks failed
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Failing after 51s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
6080041a79
Members had no in-app way to flag an abusive or spammy message; moderation was entirely moderator-initiated. Add a "Report" action to the message hover menu (shown for other members' non-system messages) that opens a modal to pick a preset category (Spam / Harassment / Inappropriate / Other) plus an optional note, and a site-admin review queue at /admin/reports to triage open reports (jump to the message, Resolve, Dismiss). Enforcement (delete / ban / mute) continues to use the existing tools; this is the intake + triage queue.

Reports persist in chat.db (new migration 0063_message_reports) with UNIQUE(message_id, reporter_id) so a re-report by the same user is a no-op, and room_id denormalized so the queue renders without a join and survives a soft-deleted message. The queue and its nav open-count badge live-update every admin tab via an AdminReportChanged broadcast on the existing `admin` topic: the WS send task re-queries and swaps the #admin-reports-list region and #admin-reports-badge OOB (id-keyed, self-limiting per LC-156).

Scope split mirrors the existing admin-surface cfg: report submission + the table are compiled in both build modes (forward-compatible storage), while the review queue, resolve/dismiss handlers, nav link, and the AdminReportChanged WS arm/renderer are #[cfg(feature = "standalone")] (in saas the event never fires and falls through to render_event None). Category allowlist and a 500-char note cap are enforced server-side. en/es strings added with parity. The 4 LC-77 synthetic-actor render fixtures gained two blank lines from the new (correctly-false-for-synthetic-actors) report conditional and were regenerated.

#LC-334
#LC-335

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-334-message-reports 2026-06-18 05:17:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!345
No description provided.