feat(admin-feedback): spam filter + mark/unmark spam + delete + Close-into-Closed-tab #114

Merged
YousifShkara merged 2 commits from feat/feedback-admin-ops-spam-and-delete into main 2026-06-11 07:04:06 +02:00
Owner

Closes BUNYIP-92. Stacks on BUNYIP-85.

Several gaps in the admin feedback console:

  1. Spam leaked into the active queue. FeedbackRepository::list_paginated did not filter is_spam; only the CSV-export list_all excluded it. A bot that triggered the honeypot landed in the working queue indistinguishable from real feedback.
  2. "Close" felt like a no-op: the row's badge flipped to "closed" but the row stayed in the list, so admins thought nothing happened.
  3. No "Mark as Spam" path: spam detection was only the honeypot field on submission; a human-written abuse / off-topic submission had no path to spam.
  4. No hard delete: the API had delete_feedback; the BFF and SSR layers did not expose it. Worse, FeedbackRepository::delete quietly required status = 'closed', so even a future Delete button would 404 on Active rows.

Changes:

  • crates/bunyip-domain/src/models/audit.rs: add AuditAction::FeedbackMarkedSpam + FeedbackUnmarkedSpam, register both as admin actions and serialize to feedback_marked_spam / feedback_unmarked_spam.
  • crates/bunyip-domain/src/repositories/feedback.rs: refactor list_paginated to take a bucket: Option<&str> parameter (active / closed / spam). Each bucket maps to a static SQL predicate (no bind values, SQL-injection-safe by construction). Active defaults exclude is_spam = TRUE AND status = 'closed'; Spam includes only is_spam = TRUE. Add set_is_spam for the mark/unmark flow. Drop the legacy AND status = 'closed' constraint on delete (admin role + JS confirm + audit log already provide three safety layers).
  • bunyip-api/src/handlers/feedback.rs: thread ?bucket= through list_feedback (validated against the allowed set; bad values 4xx). Add mark_feedback_spam and unmark_feedback_spam handlers. Both write the matching audit log on success.
  • bunyip-api/src/routes/admin.rs: register the two new POST routes adjacent to the existing status / respond / delete routes.
  • bunyip-web/src/api/admin.rs: extend feedback() to require the bucket arg. New helpers mark_feedback_spam, unmark_feedback_spam, delete_feedback.
  • bunyip-web/src/handlers/admin.rs: extend FeedbackTab from {Active, Archive} to {Active, Closed, Spam, Archive}. Add tab-aware bucket() and path() methods. Add tab links to feedback_tabs(). Extract render_feedback_list(...) + feedback_row(f, tab) so the three list handlers (Active / Closed / Spam) share the page chrome but each row gets tab-appropriate buttons. New handlers feedback_closed, feedback_spam, feedback_mark_spam, feedback_unmark_spam, feedback_delete. Status form now carries a from hidden field so the redirect lands the admin back on the originating tab with a ?toast_ok= confirmation; same for the spam / delete handlers. The detail page (BUNYIP-85) gains Mark Spam + Delete buttons.
  • bunyip-web/src/main.rs: register the new SSR routes. Closed / spam tab routes register BEFORE the :id detail catchall so axum's matcher does not interpret the literals as a feedback id.

Result: clicking Close on an Active row visibly moves the row into the Closed tab. Spam is auto-filtered from Active and reachable via its own tab. Mark Spam and Delete are one click away from every row and from the detail page; Delete prompts for confirmation. False-positive spam is recoverable via Unmark Spam.

just check-container clean (modulo the pre-existing test_config_defaults parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-92

Closes BUNYIP-92. Stacks on BUNYIP-85. Several gaps in the admin feedback console: 1. Spam leaked into the active queue. `FeedbackRepository::list_paginated` did not filter is_spam; only the CSV-export `list_all` excluded it. A bot that triggered the honeypot landed in the working queue indistinguishable from real feedback. 2. "Close" felt like a no-op: the row's badge flipped to "closed" but the row stayed in the list, so admins thought nothing happened. 3. No "Mark as Spam" path: spam detection was only the honeypot field on submission; a human-written abuse / off-topic submission had no path to spam. 4. No hard delete: the API had `delete_feedback`; the BFF and SSR layers did not expose it. Worse, `FeedbackRepository::delete` quietly required `status = 'closed'`, so even a future Delete button would 404 on Active rows. Changes: - crates/bunyip-domain/src/models/audit.rs: add `AuditAction::FeedbackMarkedSpam` + `FeedbackUnmarkedSpam`, register both as admin actions and serialize to `feedback_marked_spam` / `feedback_unmarked_spam`. - crates/bunyip-domain/src/repositories/feedback.rs: refactor `list_paginated` to take a `bucket: Option<&str>` parameter (`active` / `closed` / `spam`). Each bucket maps to a static SQL predicate (no bind values, SQL-injection-safe by construction). Active defaults exclude `is_spam = TRUE AND status = 'closed'`; Spam includes only `is_spam = TRUE`. Add `set_is_spam` for the mark/unmark flow. Drop the legacy `AND status = 'closed'` constraint on `delete` (admin role + JS confirm + audit log already provide three safety layers). - bunyip-api/src/handlers/feedback.rs: thread `?bucket=` through `list_feedback` (validated against the allowed set; bad values 4xx). Add `mark_feedback_spam` and `unmark_feedback_spam` handlers. Both write the matching audit log on success. - bunyip-api/src/routes/admin.rs: register the two new POST routes adjacent to the existing status / respond / delete routes. - bunyip-web/src/api/admin.rs: extend `feedback()` to require the bucket arg. New helpers `mark_feedback_spam`, `unmark_feedback_spam`, `delete_feedback`. - bunyip-web/src/handlers/admin.rs: extend `FeedbackTab` from {Active, Archive} to {Active, Closed, Spam, Archive}. Add tab-aware `bucket()` and `path()` methods. Add tab links to `feedback_tabs()`. Extract `render_feedback_list(...)` + `feedback_row(f, tab)` so the three list handlers (Active / Closed / Spam) share the page chrome but each row gets tab-appropriate buttons. New handlers `feedback_closed`, `feedback_spam`, `feedback_mark_spam`, `feedback_unmark_spam`, `feedback_delete`. Status form now carries a `from` hidden field so the redirect lands the admin back on the originating tab with a `?toast_ok=` confirmation; same for the spam / delete handlers. The detail page (BUNYIP-85) gains Mark Spam + Delete buttons. - bunyip-web/src/main.rs: register the new SSR routes. Closed / spam tab routes register BEFORE the `:id` detail catchall so axum's matcher does not interpret the literals as a feedback id. Result: clicking Close on an Active row visibly moves the row into the Closed tab. Spam is auto-filtered from Active and reachable via its own tab. Mark Spam and Delete are one click away from every row and from the detail page; Delete prompts for confirmation. False-positive spam is recoverable via Unmark Spam. `just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean). #BUNYIP-92
feat(admin-feedback): spam filter + mark/unmark spam + delete + Close-into-Closed-tab
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 1m18s
ef18d93f7e
Closes BUNYIP-92. Stacks on BUNYIP-85.

Several gaps in the admin feedback console:

1. Spam leaked into the active queue. `FeedbackRepository::list_paginated` did not filter is_spam; only the CSV-export `list_all` excluded it. A bot that triggered the honeypot landed in the working queue indistinguishable from real feedback.
2. "Close" felt like a no-op: the row's badge flipped to "closed" but the row stayed in the list, so admins thought nothing happened.
3. No "Mark as Spam" path: spam detection was only the honeypot field on submission; a human-written abuse / off-topic submission had no path to spam.
4. No hard delete: the API had `delete_feedback`; the BFF and SSR layers did not expose it. Worse, `FeedbackRepository::delete` quietly required `status = 'closed'`, so even a future Delete button would 404 on Active rows.

Changes:

- crates/bunyip-domain/src/models/audit.rs: add `AuditAction::FeedbackMarkedSpam` + `FeedbackUnmarkedSpam`, register both as admin actions and serialize to `feedback_marked_spam` / `feedback_unmarked_spam`.
- crates/bunyip-domain/src/repositories/feedback.rs: refactor `list_paginated` to take a `bucket: Option<&str>` parameter (`active` / `closed` / `spam`). Each bucket maps to a static SQL predicate (no bind values, SQL-injection-safe by construction). Active defaults exclude `is_spam = TRUE AND status = 'closed'`; Spam includes only `is_spam = TRUE`. Add `set_is_spam` for the mark/unmark flow. Drop the legacy `AND status = 'closed'` constraint on `delete` (admin role + JS confirm + audit log already provide three safety layers).
- bunyip-api/src/handlers/feedback.rs: thread `?bucket=` through `list_feedback` (validated against the allowed set; bad values 4xx). Add `mark_feedback_spam` and `unmark_feedback_spam` handlers. Both write the matching audit log on success.
- bunyip-api/src/routes/admin.rs: register the two new POST routes adjacent to the existing status / respond / delete routes.
- bunyip-web/src/api/admin.rs: extend `feedback()` to require the bucket arg. New helpers `mark_feedback_spam`, `unmark_feedback_spam`, `delete_feedback`.
- bunyip-web/src/handlers/admin.rs: extend `FeedbackTab` from {Active, Archive} to {Active, Closed, Spam, Archive}. Add tab-aware `bucket()` and `path()` methods. Add tab links to `feedback_tabs()`. Extract `render_feedback_list(...)` + `feedback_row(f, tab)` so the three list handlers (Active / Closed / Spam) share the page chrome but each row gets tab-appropriate buttons. New handlers `feedback_closed`, `feedback_spam`, `feedback_mark_spam`, `feedback_unmark_spam`, `feedback_delete`. Status form now carries a `from` hidden field so the redirect lands the admin back on the originating tab with a `?toast_ok=` confirmation; same for the spam / delete handlers. The detail page (BUNYIP-85) gains Mark Spam + Delete buttons.
- bunyip-web/src/main.rs: register the new SSR routes. Closed / spam tab routes register BEFORE the `:id` detail catchall so axum's matcher does not interpret the literals as a feedback id.

Result: clicking Close on an Active row visibly moves the row into the Closed tab. Spam is auto-filtered from Active and reachable via its own tab. Mark Spam and Delete are one click away from every row and from the detail page; Delete prompts for confirmation. False-positive spam is recoverable via Unmark Spam.

`just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-92
Merge remote-tracking branch 'origin/main' into feat/feedback-admin-ops-spam-and-delete
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 1m5s
Create release / Create release from merged PR (pull_request) Has been skipped
3818b2fdda
# Conflicts:
#	crates/bunyip-domain/src/models/audit.rs
YousifShkara deleted branch feat/feedback-admin-ops-spam-and-delete 2026-06-11 07:04:06 +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/bunyip!114
No description provided.