From 10f0d86fc49f212cb69fb85c874724bb2d472952 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:52:26 +0000 Subject: [PATCH] feat: restrict @predictors mentions per updated requirements - Backend: filter @predictors to only active (non-withdrawn) predictions by excluding forecasts with end_time in the past - Frontend: show warning toast when non-admin/curator uses @predictors in both comment creation and editing flows - Frontend: add mention autocomplete with permission filtering to the comment edit form (was previously only on new comment form) - Add translation key for the warning message - Add test for withdrawn forecast exclusion Fixes #4082 Co-authored-by: Sylvain --- comments/utils.py | 11 ++++- front_end/messages/en.json | 1 + .../src/components/comment_feed/comment.tsx | 19 +++++++- .../comment_feed/comment_editor.tsx | 15 ++++++- front_end/src/utils/comments.ts | 14 ++++++ tests/unit/test_comments/test_utils.py | 43 +++++++++++++++++++ 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/comments/utils.py b/comments/utils.py index 6b6397b6fc..59ab71e9ff 100644 --- a/comments/utils.py +++ b/comments/utils.py @@ -3,6 +3,7 @@ from typing import Iterable from django.db.models import Q, QuerySet +from django.utils import timezone from comments.models import Comment from projects.permissions import ObjectPermission @@ -56,10 +57,16 @@ def comment_extract_user_mentions( ObjectPermission.CURATOR ) ): + now = timezone.now() query |= Q( pk__in=User.objects.filter( - forecast__post=comment.on_post - ).distinct("pk") + forecast__post=comment.on_post, + ) + .exclude( + forecast__end_time__isnull=False, + forecast__end_time__lte=now, + ) + .distinct("pk") ) continue diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 4801a2bf09..9792d09dcc 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -372,6 +372,7 @@ "estimatedReadingTime": "{minutes} min read", "predictions": "Predictions", "predictors": "Predictors", + "predictorsMentionWarning": "Only curators and admins can notify @predictors. Your mention will not send notifications.", "relativeLog": "Relative Log", "unreadAll": "all unread", "questionsLeft": "{count, plural, =1 {1 question left} other {{count} questions left} }", diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 7a65d326fc..11b01f32a1 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -12,6 +12,8 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import toast from "react-hot-toast"; + import { softDeleteUserAction } from "@/app/(main)/accounts/profile/actions"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import KeyFactorsAddInComment from "@/app/(main)/questions/[id]/components/key_factors/add_in_comment/key_factors_add_in_comment"; @@ -49,7 +51,7 @@ import { } from "@/types/post"; import { QuestionType } from "@/types/question"; import { sendAnalyticsEvent } from "@/utils/analytics"; -import { parseUserMentions } from "@/utils/comments"; +import { hasPredictorsMention, parseUserMentions } from "@/utils/comments"; import cn from "@/utils/core/cn"; import { logError } from "@/utils/core/errors"; import { isForecastActive } from "@/utils/forecasts/helpers"; @@ -520,6 +522,19 @@ const Comment: FC = ({ if (response && "errors" in response) { setErrorMessage(response.errors as ErrorResponse); } else { + // Warn non-curators/admins if they used @predictors + const userPermission = postData?.user_permission; + if ( + hasPredictorsMention(parsedMarkdown) && + (!userPermission || + ![ + ProjectPermissions.CURATOR, + ProjectPermissions.ADMIN, + ].includes(userPermission)) + ) { + toast(t("predictorsMentionWarning")); + } + setCommentMarkdown(parsedMarkdown); setComments((prev) => updateCommentTextInTree(prev, comment.id, parsedMarkdown) @@ -853,6 +868,8 @@ const Comment: FC = ({ saveEditDraftDebounced(val); }} withUgcLinks + withUserMentions + userPermission={postData?.user_permission} withCodeBlocks /> {hadForecastAtCommentCreation && postData?.question && ( diff --git a/front_end/src/components/comment_feed/comment_editor.tsx b/front_end/src/components/comment_feed/comment_editor.tsx index 562e017531..d7585d9cbb 100644 --- a/front_end/src/components/comment_feed/comment_editor.tsx +++ b/front_end/src/components/comment_feed/comment_editor.tsx @@ -4,6 +4,8 @@ import { MDXEditorMethods } from "@mdxeditor/editor"; import { useTranslations } from "next-intl"; import { FC, useCallback, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; + import { createComment } from "@/app/(main)/questions/actions"; import MarkdownEditor from "@/components/markdown_editor"; import Button from "@/components/ui/button"; @@ -19,7 +21,7 @@ import { CommentType } from "@/types/comment"; import { ErrorResponse } from "@/types/fetch"; import { ProjectPermissions } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; -import { parseComment } from "@/utils/comments"; +import { hasPredictorsMention, parseComment } from "@/utils/comments"; import { validateComment } from "./validate_comment"; @@ -166,6 +168,17 @@ const CommentEditor: FC = ({ stopAndDiscardDraft(); + // Warn non-curators/admins if they used @predictors + if ( + hasPredictorsMention(parsedMarkdown) && + (!userPermission || + ![ProjectPermissions.CURATOR, ProjectPermissions.ADMIN].includes( + userPermission + )) + ) { + toast(t("predictorsMentionWarning")); + } + setHasIncludedForecast(false); markdownRef.current = ""; setHasContent(false); diff --git a/front_end/src/utils/comments.ts b/front_end/src/utils/comments.ts index e4309b5a22..a89ef934be 100644 --- a/front_end/src/utils/comments.ts +++ b/front_end/src/utils/comments.ts @@ -77,6 +77,20 @@ export function parseUserMentions( return markdown; } +/** + * Checks if a comment text contains an @predictors mention. + */ +export function hasPredictorsMention(text: string): boolean { + const mentions = text.matchAll(userTagPattern); + for (const match of mentions) { + const username = (match[1] || match[2] || "").toLowerCase(); + if (username === "predictors") { + return true; + } + } + return false; +} + /** * Returns commentId to focus on if id is provided and comment is not already rendered */ diff --git a/tests/unit/test_comments/test_utils.py b/tests/unit/test_comments/test_utils.py index 8b24e3a16f..97554a5cbe 100644 --- a/tests/unit/test_comments/test_utils.py +++ b/tests/unit/test_comments/test_utils.py @@ -1,6 +1,10 @@ +from datetime import timedelta + import pytest import re +from django.utils import timezone + from comments.utils import ( USERNAME_PATTERN, get_mention_for_user, @@ -150,3 +154,42 @@ def test_comment_extract_user_mentions( assert {x.username for x in qs} == expected_usernames assert mentions == expected_mentions + + +@pytest.mark.django_db +def test_predictors_mention_excludes_withdrawn_forecasts(question_binary): + """@predictors should only notify users with active (non-withdrawn) predictions.""" + active_forecaster = factory_user(username="active_forecaster") + withdrawn_forecaster = factory_user(username="withdrawn_forecaster") + admin = factory_user(username="admin") + + post = factory_post( + question=question_binary, + default_project=factory_project( + type=Project.ProjectTypes.TOURNAMENT, + default_permission=ObjectPermission.FORECASTER, + override_permissions={ + admin: ObjectPermission.ADMIN, + }, + ), + ) + + # Active forecast (no end_time) + factory_forecast(question=question_binary, author=active_forecaster) + # Withdrawn forecast (end_time in the past) + factory_forecast( + question=question_binary, + author=withdrawn_forecaster, + end_time=timezone.now() - timedelta(days=1), + ) + + qs, mentions = comment_extract_user_mentions( + factory_comment( + author=admin, + on_post=post, + text_original="Wanna mention @predictors", + ) + ) + + assert {x.username for x in qs} == {"active_forecaster"} + assert mentions == {"predictors"}