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
+15
View File
@@ -295,6 +295,21 @@ router.post('/:slug/photos/:photoId/feedback',
// Moderate the comment
const moderationResult = await feedbackModeration.moderateText(req.body.comment_text);
if (moderationResult.blocked) {
// `block` severity means rejected outright — never stored, not even
// as a pending row for a moderator to see. Anything else that isn't
// approved falls through to the held-for-moderation branch below.
logger.warn('Comment rejected by word filter:', {
eventId: event.id,
reason: moderationResult.reason,
violations: moderationResult.violations
});
return res.status(400).json({
error: 'Your comment contains words that are not allowed here.',
code: 'COMMENT_BLOCKED'
});
}
if (!moderationResult.approved) {
// Still save but mark as not approved
feedbackData.is_approved = false;
@@ -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');