fix(feedback): make the "block" severity tier actually reject

The block level is advertised as "comment is rejected immediately", but every
non-approved comment was saved with is_approved = false instead of the
submission being refused.

moderateText now sets an explicit `blocked: true` on the blocking-violation
branch -- branching on the reason string in the route would have been fragile
-- and the route 400s with code COMMENT_BLOCKED and stores nothing. Everything
else that is not approved (moderate/high, the spam and caps checks, and the
"Moderation system error" fallback) deliberately omits the flag and keeps the
held-for-moderation path, so a moderation failure still fails safe.

Also fixes an adjacent defect that made the tier split unobservable:
feedbackService.submitFeedback ignored feedbackData.is_approved entirely and
hard-derived is_approved from moderate_comments. So a moderate/high
word-filter hit on an event with moderation switched OFF was published
immediately -- the route's `feedbackData.is_approved = false` was dead code.
Now honoured one-directionally: a caller-supplied false is respected, but
nothing a caller passes can RELAX the event's setting. That deliberately
leaves the route's reputation.autoApprove -> is_approved = true branch inert
rather than letting a trusted guest bypass an event's moderation setting.

Refs testplan REPORT.md B11.

(cherry picked from commit b1b57b1615aaf02fe76e789a86b7e11933288d77)
This commit is contained in:
Paul Nothaft
2026-09-02 09:30:49 +02:00
parent da8fcc82ef
commit 814f205da0
4 changed files with 149 additions and 1 deletions
@@ -71,8 +71,15 @@ class FeedbackModerationService {
// low/moderate/high/block levels — rows stored under it still apply.
const isBlocking = (v) => v.severity === 'block' || v.severity === 'severe';
if (violations.some(isBlocking)) {
// `blocked` is the flag the submit route branches on: the `block`
// tier is advertised as "comment is rejected immediately", so it 4xxs
// the submission instead of storing it for a moderator. Every other
// not-approved outcome (moderate/high, spam checks, and the
// moderation-system-error fallback below) deliberately omits it and
// keeps the held-for-moderation behaviour.
return {
approved: false,
blocked: true,
reason: 'Content contains prohibited words',
violations: violations.filter(isBlocking)
};
+8 -1
View File
@@ -645,7 +645,14 @@ class FeedbackService {
guest_id: guest_id || null,
ip_address,
user_agent,
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
// The submit route can force a comment into moderation (a
// `moderate`/`high` word-filter hit) on an event whose
// moderate_comments is off — this line used to ignore that entirely,
// so those hits published straight away. A caller-supplied `false` is
// honoured; nothing a caller passes can RELAX the event's setting.
is_approved: feedbackData.is_approved === false
? false
: (feedback_type !== 'comment' || !feedbackData.moderate_comments),
created_at: new Date(),
updated_at: new Date()
}).returning('id');