diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c2d365da..2b8014ae33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to ## [Unreleased] ### Added - +- ✨(backend) add limit on distinct reactions per comment #1978 - ✨(backend) create a dedicated endpoint to update document content - ⚡️(backend) stream s3 file content with a dedicated endpoint diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8298f211f0..a6330e2d5a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -2838,6 +2838,7 @@ def get(self, request): "POSTHOG_KEY", "LANGUAGES", "LANGUAGE_CODE", + "REACTIONS_MAX_PER_COMMENT", "SENTRY_DSN", "TRASHBIN_CUTOFF_DAYS", ] @@ -2955,7 +2956,11 @@ 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.""" @@ -2989,9 +2994,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 91bdaeaf6c..3f5a801cd9 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -236,6 +236,11 @@ class Meta: comment = factory.SubFactory(CommentFactory) emoji = factory.Faker("emoji") + @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 85c90aa84c..c1fea563b6 100644 --- a/src/backend/core/tests/documents/test_api_documents_comments.py +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -934,3 +934,56 @@ def test_delete_reaction_owned_by_the_current_user(): reaction.refresh_from_db() assert reaction.users.exists() + + +def test_create_reaction_exceeds_maximum(settings): + """ + 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/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 6fb8a30ca8..48f9b183fb 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -75,6 +75,7 @@ def test_api_config(is_authenticated): "LANGUAGE_CODE": "en-us", "MEDIA_BASE_URL": "http://testserver/", "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, + "REACTIONS_MAX_PER_COMMENT": 15, "SENTRY_DSN": "https://sentry.test/123", "TRASHBIN_CUTOFF_DAYS": 30, "theme_customization": {}, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 4a7360231e..1728033bc2 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -190,6 +190,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 d15ba71f0e..a04552fba9 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -56,6 +56,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..081d81af29 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, ) { 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 6dbd3cbfbb..769b8313dc 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 @@ -5,6 +5,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { User, avatarUrlFromName } from '@/features/auth'; import { useEditorStore } from '@/features/docs/doc-editor/stores'; import { Doc, useProviderStore } from '@/features/docs/doc-management'; +import { useConfig } from '@/core'; import { DocsThreadStore } from './DocsThreadStore'; import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; @@ -18,6 +19,7 @@ export function useComments( const { t } = useTranslation(); const { themeTokens } = useCunninghamTheme(); const { setThreadStore } = useEditorStore(); + const { data: config } = useConfig(); const threadStore = useMemo(() => { return new DocsThreadStore( @@ -26,9 +28,16 @@ export function useComments( new DocsThreadStoreAuth( encodeURIComponent(user?.full_name || ''), canComment, + config?.REACTIONS_MAX_PER_COMMENT ?? 0, ), ); - }, [docId, canComment, provider?.awareness, user?.full_name]); + }, [ + docId, + canComment, + provider?.awareness, + user?.full_name, + config?.REACTIONS_MAX_PER_COMMENT, + ]); useEffect(() => { if (canComment) {