diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1208be07..9b582962fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to ### Added - ✨(backend) add a is_first_connection flag to the User model#1938 +### Changed + +- ✨(backend) add limit on distinct reactions per comment #1978 ## [v4.7.0] - 2026-03-09 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d9bd360c6..4fd60830c3 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2625,6 +2625,7 @@ def get(self, request): "POSTHOG_KEY", "LANGUAGES", "LANGUAGE_CODE", + "REACTIONS_MAX_PER_COMMENT", "SENTRY_DSN", "TRASHBIN_CUTOFF_DAYS", ] @@ -2742,7 +2743,9 @@ class CommentViewSet( permission_classes = [permissions.CommentPermission] pagination_class = Pagination serializer_class = serializers.CommentSerializer - queryset = models.Comment.objects.select_related("user").all() + queryset = models.Comment.objects.select_related("user").prefetch_related( + "reactions__users" + ).all() def get_queryset(self): """Override to filter on related resource.""" @@ -2776,9 +2779,29 @@ def reactions(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) if request.method == "POST": + emoji = serializer.validated_data["emoji"] + + if ( + not models.Reaction.objects.filter( + comment=comment, emoji=emoji + ).exists() + and comment.reactions.count() >= settings.REACTIONS_MAX_PER_COMMENT + ): + return drf.response.Response( + { + "emoji": [ + _( + "A comment can have a maximum of %(max)d distinct reactions." + ) + % {"max": settings.REACTIONS_MAX_PER_COMMENT} + ] + }, + status=status.HTTP_400_BAD_REQUEST, + ) + reaction, created = models.Reaction.objects.get_or_create( comment=comment, - emoji=serializer.validated_data["emoji"], + emoji=emoji, ) if not created and reaction.users.filter(id=request.user.id).exists(): return drf.response.Response( diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index acc39e06bd..f9ac0af962 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -235,6 +235,11 @@ class Meta: comment = factory.SubFactory(CommentFactory) emoji = "test" + @classmethod + def generate_emojis(cls, n=10): + """Generate a list of n unique emojis.""" + return [fake.unique.emoji() for _ in range(n)] + @factory.post_generation def users(self, create, extracted, **kwargs): """Add users to reaction from a given list of users or create one if not provided.""" diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py index 98cbc0ef98..e4749bec77 100644 --- a/src/backend/core/tests/documents/test_api_documents_comments.py +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -7,6 +7,8 @@ import pytest from rest_framework.test import APIClient +from django.conf import settings + from core import factories, models pytestmark = pytest.mark.django_db @@ -876,3 +878,56 @@ def test_delete_reaction_owned_by_the_current_user(): reaction.refresh_from_db() assert reaction.users.exists() + + +def test_create_reaction_exceeds_maximum(): + """ + Users should not be able to add more than REACTIONS_MAX_PER_COMMENT + (here we set it to 10) distinct emoji reactions to a comment. + They should, however, be able to add themselves to an existing reaction. + """ + user1 = factories.UserFactory() + user2 = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[(user1, models.RoleChoices.ADMIN), (user2, models.RoleChoices.ADMIN)], + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + + client = APIClient() + client.force_login(user1) + + # Add max distinct reactions + max_reactions = settings.REACTIONS_MAX_PER_COMMENT + emojis = factories.ReactionFactory.generate_emojis(max_reactions + 1) + for emoji in emojis[:max_reactions]: + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emoji}, + ) + assert response.status_code == 201 + + # Attempt to add another distinct reaction + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emojis[max_reactions]}, + ) + assert response.status_code == 400 + expected_message = ( + f"A comment can have a maximum of {max_reactions} distinct reactions." + ) + assert response.json() == {"emoji": [expected_message]} + + # Attempt to add user2 to one of the existing reactions (should succeed) + client.force_login(user2) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": emojis[0]}, + ) + assert response.status_code == 201 + reaction = models.Reaction.objects.get(comment=comment, emoji=emojis[0]) + assert reaction.users.count() == 2 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index b75da3d4f9..a0488f7e5e 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -182,6 +182,12 @@ class Base(Configuration): environ_prefix=None, ) + REACTIONS_MAX_PER_COMMENT = values.IntegerValue( + 15, + environ_name="REACTIONS_MAX_PER_COMMENT", + environ_prefix=None, + ) + DOCUMENT_UNSAFE_MIME_TYPES = [ # Executable Files "application/x-msdownload", diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 936747d8a9..3ffc7f18f9 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -48,6 +48,7 @@ export interface ConfigResponse { MEDIA_BASE_URL?: string; POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; + REACTIONS_MAX_PER_COMMENT: number; TRASHBIN_CUTOFF_DAYS?: number; theme_customization?: ThemeCustomization; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx index 57f614813f..c98e951ad9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx @@ -6,6 +6,7 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth { constructor( private readonly userId: string, public canSee: boolean, + private readonly maxReactions: number = 10, ) { super(); } @@ -68,13 +69,27 @@ export class DocsThreadStoreAuth extends ThreadStoreAuth { } if (!emoji) { - return true; + return comment.reactions.length < this.maxReactions; } - return !comment.reactions.some( + const hasReactedWithEmoji = comment.reactions.some( (reaction) => reaction.emoji === emoji && reaction.userIds.includes(this.userId), ); + + if (hasReactedWithEmoji) { + return false; + } + + const reactionExists = comment.reactions.some( + (reaction) => reaction.emoji === emoji, + ); + + if (reactionExists) { + return true; + } + + return comment.reactions.length < this.maxReactions; } canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts index 33a67a04da..18baab5d54 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useCunninghamTheme } from '@/cunningham'; import { User, avatarUrlFromName } from '@/features/auth'; import { Doc, useProviderStore } from '@/features/docs/doc-management'; +import { useConfig } from '@/core'; import { DocsThreadStore } from './DocsThreadStore'; import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; @@ -16,6 +17,7 @@ export function useComments( const { provider } = useProviderStore(); const { t } = useTranslation(); const { themeTokens } = useCunninghamTheme(); + const { data: config } = useConfig(); const threadStore = useMemo(() => { return new DocsThreadStore( @@ -24,9 +26,16 @@ export function useComments( new DocsThreadStoreAuth( encodeURIComponent(user?.full_name || ''), canComment, + config?.REACTIONS_MAX_PER_COMMENT, ), ); - }, [docId, canComment, provider?.awareness, user?.full_name]); + }, [ + docId, + canComment, + provider?.awareness, + user?.full_name, + config?.REACTIONS_MAX_PER_COMMENT, + ]); useEffect(() => { return () => {