diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db1a7e381..7a4bc5447f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to - ✨(frontend) create skeleton component for DocEditor #1491 - ✨(frontend) add an EmojiPicker in the document tree and title #1381 - ✨(frontend) ajustable left panel #1456 +- ✨ Add comments feature to the editor #1330 ### Changed @@ -155,6 +156,7 @@ and this project adheres to ### Added +- ✨(backend) Comments on text editor #1309 - 👷(CI) add bundle size check job #1268 - ✨(frontend) use title first emoji as doc icon in tree #1289 diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 09007847bf..29df311c82 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -171,3 +171,19 @@ def has_object_permission(self, request, view, obj): action = view.action return abilities.get(action, False) + + +class CommentPermission(permissions.BasePermission): + """Permission class for comments.""" + + def has_permission(self, request, view): + """Check permission for a given object.""" + if view.action in ["create", "list"]: + document_abilities = view.get_document_or_404().get_abilities(request.user) + return document_abilities["comment"] + + return True + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + return obj.get_abilities(request.user).get(view.action, False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 81b26d5e80..732b940a3e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,4 +1,5 @@ """Client serializers for the impress core app.""" +# pylint: disable=too-many-lines import binascii import mimetypes @@ -889,3 +890,124 @@ class MoveDocumentSerializer(serializers.Serializer): choices=enums.MoveNodePositionChoices.choices, default=enums.MoveNodePositionChoices.LAST_CHILD, ) + + +class ReactionSerializer(serializers.ModelSerializer): + """Serialize reactions.""" + + users = UserLightSerializer(many=True, read_only=True) + + class Meta: + model = models.Reaction + fields = [ + "id", + "emoji", + "created_at", + "users", + ] + read_only_fields = ["id", "created_at", "users"] + + +class CommentSerializer(serializers.ModelSerializer): + """Serialize comments (nested under a thread) with reactions and abilities.""" + + user = UserLightSerializer(read_only=True) + abilities = serializers.SerializerMethodField() + reactions = ReactionSerializer(many=True, read_only=True) + + class Meta: + model = models.Comment + fields = [ + "id", + "user", + "body", + "created_at", + "updated_at", + "reactions", + "abilities", + ] + read_only_fields = [ + "id", + "user", + "created_at", + "updated_at", + "reactions", + "abilities", + ] + + def validate(self, attrs): + """Validate comment data.""" + + request = self.context.get("request") + user = getattr(request, "user", None) + + attrs["thread_id"] = self.context["thread_id"] + attrs["user_id"] = user.id if user else None + return attrs + + def get_abilities(self, obj): + """Return comment's abilities.""" + request = self.context.get("request") + if request: + return obj.get_abilities(request.user) + return {} + + +class ThreadSerializer(serializers.ModelSerializer): + """Serialize threads in a backward compatible shape for current frontend. + + We expose a flatten representation where ``content`` maps to the first + comment's body. Creating a thread requires a ``content`` field which is + stored as the first comment. + """ + + creator = UserLightSerializer(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) + body = serializers.JSONField(write_only=True, required=True) + comments = serializers.SerializerMethodField(read_only=True) + comments = CommentSerializer(many=True, read_only=True) + + class Meta: + model = models.Thread + fields = [ + "id", + "body", + "created_at", + "updated_at", + "creator", + "abilities", + "comments", + "resolved", + "resolved_at", + "resolved_by", + "metadata", + ] + read_only_fields = [ + "id", + "created_at", + "updated_at", + "creator", + "abilities", + "comments", + "resolved", + "resolved_at", + "resolved_by", + "metadata", + ] + + def validate(self, attrs): + """Validate thread data.""" + request = self.context.get("request") + user = getattr(request, "user", None) + + attrs["document_id"] = self.context["resource_id"] + attrs["creator_id"] = user.id if user else None + + return attrs + + def get_abilities(self, thread): + """Return thread's abilities.""" + request = self.context.get("request") + if request: + return thread.get_abilities(request.user) + return {} diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 84402ceaae..fdf06ec0fd 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -21,6 +21,7 @@ from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse from django.urls import reverse +from django.utils import timezone from django.utils.functional import cached_property from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -2236,3 +2237,132 @@ def _load_theme_customization(self): ) return theme_customization + + +class CommentViewSetMixin: + """Comment ViewSet Mixin.""" + + _document = None + + def get_document_or_404(self): + """Get the document related to the viewset or raise a 404 error.""" + if self._document is None: + try: + self._document = models.Document.objects.get( + pk=self.kwargs["resource_id"], + ) + except models.Document.DoesNotExist as e: + raise drf.exceptions.NotFound("Document not found.") from e + return self._document + + +class ThreadViewSet( + ResourceAccessViewsetMixin, + CommentViewSetMixin, + drf.mixins.CreateModelMixin, + drf.mixins.ListModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + """Thread API: list/create threads and nested comment operations.""" + + permission_classes = [permissions.CommentPermission] + pagination_class = Pagination + serializer_class = serializers.ThreadSerializer + queryset = models.Thread.objects.select_related("creator", "document").filter( + resolved=False + ) + resource_field_name = "document" + + def perform_create(self, serializer): + """Create the first comment of the thread.""" + body = serializer.validated_data["body"] + del serializer.validated_data["body"] + thread = serializer.save() + + models.Comment.objects.create( + thread=thread, + user=self.request.user if self.request.user.is_authenticated else None, + body=body, + ) + + @drf.decorators.action(detail=True, methods=["post"], url_path="resolve") + def resolve(self, request, *args, **kwargs): + """Resolve a thread.""" + thread = self.get_object() + if not thread.resolved: + thread.resolved = True + thread.resolved_at = timezone.now() + thread.resolved_by = request.user + thread.save(update_fields=["resolved", "resolved_at", "resolved_by"]) + return drf.response.Response(status=status.HTTP_204_NO_CONTENT) + + +class CommentViewSet( + CommentViewSetMixin, + viewsets.ModelViewSet, +): + """Comment API: list/create comments and nested reaction operations.""" + + permission_classes = [permissions.CommentPermission] + pagination_class = Pagination + serializer_class = serializers.CommentSerializer + queryset = models.Comment.objects.select_related("user").all() + + def get_queryset(self): + """Override to filter on related resource.""" + return ( + super() + .get_queryset() + .filter( + thread=self.kwargs["thread_id"], + thread__document=self.kwargs["resource_id"], + ) + ) + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["document_id"] = self.kwargs["resource_id"] + context["thread_id"] = self.kwargs["thread_id"] + return context + + @drf.decorators.action( + detail=True, + methods=["post", "delete"], + ) + def reactions(self, request, *args, **kwargs): + """POST: add reaction; DELETE: remove reaction. + + Emoji is expected in request.data['emoji'] for both operations. + """ + comment = self.get_object() + serializer = serializers.ReactionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if request.method == "POST": + reaction, created = models.Reaction.objects.get_or_create( + comment=comment, + emoji=serializer.validated_data["emoji"], + ) + if not created and reaction.users.filter(id=request.user.id).exists(): + return drf.response.Response( + {"user_already_reacted": True}, status=status.HTTP_400_BAD_REQUEST + ) + reaction.users.add(request.user) + return drf.response.Response(status=status.HTTP_201_CREATED) + + # DELETE + try: + reaction = models.Reaction.objects.get( + comment=comment, + emoji=serializer.validated_data["emoji"], + users__in=[request.user], + ) + except models.Reaction.DoesNotExist as e: + raise drf.exceptions.NotFound("Reaction not found.") from e + reaction.users.remove(request.user) + if not reaction.users.exists(): + reaction.delete() + return drf.response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index e6b975111a..e185153103 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -33,6 +33,7 @@ class LinkRoleChoices(PriorityTextChoices): """Defines the possible roles a link can offer on a document.""" READER = "reader", _("Reader") # Can read + COMMENTER = "commenter", _("Commenter") # Can read and comment EDITOR = "editor", _("Editor") # Can read and edit @@ -40,6 +41,7 @@ class RoleChoices(PriorityTextChoices): """Defines the possible roles a user can have in a resource.""" READER = "reader", _("Reader") # Can read + COMMENTER = "commenter", _("Commenter") # Can read and comment EDITOR = "editor", _("Editor") # Can read and edit ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share OWNER = "owner", _("Owner") diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 1b3715e749..c0737cdce9 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -256,3 +256,49 @@ class Meta: document = factory.SubFactory(DocumentFactory) role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) issuer = factory.SubFactory(UserFactory) + + +class ThreadFactory(factory.django.DjangoModelFactory): + """A factory to create threads for a document""" + + class Meta: + model = models.Thread + + document = factory.SubFactory(DocumentFactory) + creator = factory.SubFactory(UserFactory) + + +class CommentFactory(factory.django.DjangoModelFactory): + """A factory to create comments for a thread""" + + class Meta: + model = models.Comment + + thread = factory.SubFactory(ThreadFactory) + user = factory.SubFactory(UserFactory) + body = factory.Faker("text") + + +class ReactionFactory(factory.django.DjangoModelFactory): + """A factory to create reactions for a comment""" + + class Meta: + model = models.Reaction + + comment = factory.SubFactory(CommentFactory) + emoji = "test" + + @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.""" + if not create: + return + + if not extracted: + # the factory is being created, but no users were provided + user = UserFactory() + self.users.add(user) + return + + # Add the iterable of groups using bulk addition + self.users.add(*extracted) diff --git a/src/backend/core/migrations/0026_comments.py b/src/backend/core/migrations/0026_comments.py new file mode 100644 index 0000000000..f1b122f3e2 --- /dev/null +++ b/src/backend/core/migrations/0026_comments.py @@ -0,0 +1,275 @@ +# Generated by Django 5.2.6 on 2025-09-16 08:59 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0025_alter_user_short_name"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="link_role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="documentaskforaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="invitation", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.AlterField( + model_name="templateaccess", + name="role", + field=models.CharField( + choices=[ + ("reader", "Reader"), + ("commenter", "Commenter"), + ("editor", "Editor"), + ("administrator", "Administrator"), + ("owner", "Owner"), + ], + default="reader", + max_length=20, + ), + ), + migrations.CreateModel( + name="Thread", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("resolved", models.BooleanField(default=False)), + ("resolved_at", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "creator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="threads", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="threads", + to="core.document", + ), + ), + ( + "resolved_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="resolved_threads", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Thread", + "verbose_name_plural": "Threads", + "db_table": "impress_thread", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("body", models.JSONField()), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="thread_comment", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "thread", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="core.thread", + ), + ), + ], + options={ + "verbose_name": "Comment", + "verbose_name_plural": "Comments", + "db_table": "impress_comment", + "ordering": ("created_at",), + }, + ), + migrations.CreateModel( + name="Reaction", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + help_text="primary key for the record as UUID", + primary_key=True, + serialize=False, + verbose_name="id", + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="date and time at which a record was created", + verbose_name="created on", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="date and time at which a record was last updated", + verbose_name="updated on", + ), + ), + ("emoji", models.CharField(max_length=32)), + ( + "comment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reactions", + to="core.comment", + ), + ), + ( + "users", + models.ManyToManyField( + related_name="reactions", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "verbose_name": "Reaction", + "verbose_name_plural": "Reactions", + "db_table": "impress_comment_reaction", + "constraints": [ + models.UniqueConstraint( + fields=("comment", "emoji"), + name="unique_comment_emoji", + violation_error_message="This emoji has already been reacted to this comment.", + ) + ], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 6e0ad69e4f..0e09291a69 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -756,6 +756,7 @@ def get_abilities(self, user): can_update = ( is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted + can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted can_create_children = can_update and user.is_authenticated can_destroy = ( is_owner @@ -786,6 +787,7 @@ def get_abilities(self, user): "children_list": can_get, "children_create": can_create_children, "collaboration_auth": can_get, + "comment": can_comment, "content": can_get, "cors_proxy": can_get, "descendants": can_get, @@ -1146,7 +1148,12 @@ def get_abilities(self, user): set_role_to = [] if is_owner_or_admin: set_role_to.extend( - [RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN] + [ + RoleChoices.READER, + RoleChoices.COMMENTER, + RoleChoices.EDITOR, + RoleChoices.ADMIN, + ] ) if role == RoleChoices.OWNER: set_role_to.append(RoleChoices.OWNER) @@ -1278,6 +1285,153 @@ def send_ask_for_access_email(self, email, language=None): self.document.send_email(subject, [email], context, language) +class Thread(BaseModel): + """Discussion thread attached to a document. + + A thread groups one or many comments. For backward compatibility with the + existing frontend (useComments hook) we still expose a flattened serializer + that returns a "content" field representing the first comment's body. + """ + + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="threads", + ) + creator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="threads", + null=True, + blank=True, + ) + resolved = models.BooleanField(default=False) + resolved_at = models.DateTimeField(null=True, blank=True) + resolved_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="resolved_threads", + null=True, + blank=True, + ) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "impress_thread" + ordering = ("-created_at",) + verbose_name = _("Thread") + verbose_name_plural = _("Threads") + + def __str__(self): + author = self.creator or _("Anonymous") + return f"Thread by {author!s} on {self.document!s}" + + def get_abilities(self, user): + """Compute and return abilities for a given user (mirrors comment logic).""" + role = self.document.get_role(user) + doc_abilities = self.document.get_abilities(user) + read_access = doc_abilities.get("comment", False) + write_access = self.creator == user or role in [ + RoleChoices.OWNER, + RoleChoices.ADMIN, + ] + return { + "destroy": write_access, + "update": write_access, + "partial_update": write_access, + "resolve": write_access, + "retrieve": read_access, + } + + @property + def first_comment(self): + """Return the first createdcomment of the thread.""" + return self.comments.order_by("created_at").first() + + +class Comment(BaseModel): + """A comment belonging to a thread.""" + + thread = models.ForeignKey( + Thread, + on_delete=models.CASCADE, + related_name="comments", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="thread_comment", + null=True, + blank=True, + ) + body = models.JSONField() + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "impress_comment" + ordering = ("created_at",) + verbose_name = _("Comment") + verbose_name_plural = _("Comments") + + def __str__(self): + """Return the string representation of the comment.""" + author = self.user or _("Anonymous") + return f"Comment by {author!s} on thread {self.thread_id}" + + def get_abilities(self, user): + """Return the abilities of the comment.""" + role = self.thread.document.get_role(user) + doc_abilities = self.thread.document.get_abilities(user) + read_access = doc_abilities.get("comment", False) + can_react = read_access and user.is_authenticated + write_access = self.user == user or role in [ + RoleChoices.OWNER, + RoleChoices.ADMIN, + ] + return { + "destroy": write_access, + "update": write_access, + "partial_update": write_access, + "reactions": can_react, + "retrieve": read_access, + } + + +class Reaction(BaseModel): + """Aggregated reactions for a given emoji on a comment. + + We store one row per (comment, emoji) and maintain the list of user IDs who + reacted with that emoji. This matches the frontend interface where a + reaction exposes: emoji, createdAt (first reaction date) and userIds. + """ + + comment = models.ForeignKey( + Comment, + on_delete=models.CASCADE, + related_name="reactions", + ) + emoji = models.CharField(max_length=32) + users = models.ManyToManyField(User, related_name="reactions") + + class Meta: + db_table = "impress_comment_reaction" + constraints = [ + models.UniqueConstraint( + fields=["comment", "emoji"], + name="unique_comment_emoji", + violation_error_message=_( + "This emoji has already been reacted to this comment." + ), + ), + ] + verbose_name = _("Reaction") + verbose_name_plural = _("Reactions") + + def __str__(self): + """Return the string representation of the reaction.""" + return f"Reaction {self.emoji} on comment {self.comment.id}" + + class Template(BaseModel): """HTML and CSS code used for formatting the print around the MarkDown body.""" diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 280b2bc921..aa21544cae 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -293,6 +293,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): } assert result_dict[str(document_access_other_user.id)] == [ "reader", + "commenter", "editor", "administrator", "owner", @@ -301,7 +302,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): # Add an access for the other user on the parent parent_access_other_user = factories.UserDocumentAccessFactory( - document=parent, user=other_user, role="editor" + document=parent, user=other_user, role="commenter" ) response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") @@ -314,6 +315,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): result["id"]: result["abilities"]["set_role_to"] for result in content } assert result_dict[str(document_access_other_user.id)] == [ + "commenter", "editor", "administrator", "owner", @@ -321,6 +323,7 @@ def test_api_document_accesses_retrieve_set_role_to_child(): assert result_dict[str(parent_access.id)] == [] assert result_dict[str(parent_access_other_user.id)] == [ "reader", + "commenter", "editor", "administrator", "owner", @@ -333,28 +336,28 @@ def test_api_document_accesses_retrieve_set_role_to_child(): [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], ], @@ -415,44 +418,44 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["administrator", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], ], ], [ ["owner", "reader", "reader", "reader"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["owner", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "reader", "reader", "owner"], [ - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], [], [], - ["reader", "editor", "administrator", "owner"], + ["reader", "commenter", "editor", "administrator", "owner"], ], ], [ ["reader", "administrator", "reader", "editor"], [ - ["reader", "editor", "administrator"], - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], [], ], @@ -460,7 +463,7 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul [ ["editor", "editor", "administrator", "editor"], [ - ["reader", "editor", "administrator"], + ["reader", "commenter", "editor", "administrator"], [], ["editor", "administrator"], [], diff --git a/src/backend/core/tests/documents/test_api_documents_comments.py b/src/backend/core/tests/documents/test_api_documents_comments.py new file mode 100644 index 0000000000..98cbc0ef98 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_comments.py @@ -0,0 +1,878 @@ +"""Test API for comments on documents.""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +# List comments + + +def test_list_comments_anonymous_user_public_document(): + """Anonymous users should be allowed to list comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment1, comment2 = factories.CommentFactory.create_batch(2, thread=thread) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": str(comment1.id), + "body": comment1.body, + "created_at": comment1.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment1.user.full_name, + "short_name": comment1.user.short_name, + }, + "abilities": comment1.get_abilities(AnonymousUser()), + "reactions": [], + }, + { + "id": str(comment2.id), + "body": comment2.body, + "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment2.user.full_name, + "short_name": comment2.user.short_name, + }, + "abilities": comment2.get_abilities(AnonymousUser()), + "reactions": [], + }, + ], + } + + +@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"]) +def test_list_comments_anonymous_user_non_public_document(link_reach): + """Anonymous users should not be allowed to list comments on a non-public document.""" + document = factories.DocumentFactory( + link_reach=link_reach, link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + response = APIClient().get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + assert response.status_code == 401 + + +def test_list_comments_authenticated_user_accessible_document(): + """Authenticated users should be allowed to list comments on an accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] + ) + thread = factories.ThreadFactory(document=document) + comment1 = factories.CommentFactory(thread=thread) + comment2 = factories.CommentFactory(thread=thread, user=user) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + assert response.status_code == 200 + assert response.json() == { + "count": 2, + "next": None, + "previous": None, + "results": [ + { + "id": str(comment1.id), + "body": comment1.body, + "created_at": comment1.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment1.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment1.user.full_name, + "short_name": comment1.user.short_name, + }, + "abilities": comment1.get_abilities(user), + "reactions": [], + }, + { + "id": str(comment2.id), + "body": comment2.body, + "created_at": comment2.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment2.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment2.user.full_name, + "short_name": comment2.user.short_name, + }, + "abilities": comment2.get_abilities(user), + "reactions": [], + }, + ], + } + + +def test_list_comments_authenticated_user_non_accessible_document(): + """Authenticated users should not be allowed to list comments on a non-accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + assert response.status_code == 403 + + +def test_list_comments_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to list comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + thread = factories.ThreadFactory(document=document) + factories.CommentFactory(thread=thread) + # other comments not linked to the document + factories.CommentFactory.create_batch(2) + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/" + ) + assert response.status_code == 403 + + +# Create comment + + +def test_create_comment_anonymous_user_public_document(): + """ + Anonymous users should be allowed to create comments on a public document + with commenter link_role. + """ + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, + ) + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "body": "test", + "created_at": response.json()["created_at"], + "updated_at": response.json()["updated_at"], + "user": None, + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + "reactions": [], + } + + +def test_create_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to create comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, + ) + + assert response.status_code == 401 + + +def test_create_comment_authenticated_user_accessible_document(): + """Authenticated users should be allowed to create comments on an accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] + ) + thread = factories.ThreadFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, + ) + assert response.status_code == 201 + + assert response.json() == { + "id": str(response.json()["id"]), + "body": "test", + "created_at": response.json()["created_at"], + "updated_at": response.json()["updated_at"], + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + "reactions": [], + } + + +def test_create_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to create comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + thread = factories.ThreadFactory(document=document) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/", + {"body": "test"}, + ) + assert response.status_code == 403 + + +# Retrieve comment + + +def test_retrieve_comment_anonymous_user_public_document(): + """Anonymous users should be allowed to retrieve comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 200 + assert response.json() == { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": comment.user.full_name, + "short_name": comment.user.short_name, + }, + "reactions": [], + "abilities": comment.get_abilities(AnonymousUser()), + } + + +def test_retrieve_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to retrieve comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +def test_retrieve_comment_authenticated_user_accessible_document(): + """Authenticated users should be allowed to retrieve comments on an accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 200 + + +def test_retrieve_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to retrieve comments on a document they don't have + comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 + + +# Update comment + + +def test_update_comment_anonymous_user_public_document(): + """Anonymous users should not be allowed to update comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 401 + + +def test_update_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to update comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 401 + + +def test_update_comment_authenticated_user_accessible_document(): + """Authenticated users should not be able to update comments not their own.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[ + ( + user, + random.choice( + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 403 + + +def test_update_comment_authenticated_user_own_comment(): + """Authenticated users should be able to update comments not their own.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + users=[ + ( + user, + random.choice( + [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] + ), + ) + ], + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test", user=user) + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.body == "other content" + + +def test_update_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be allowed to update comments on a document they don't + have comment access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 403 + + +def test_update_comment_authenticated_no_access(): + """ + Authenticated users should not be allowed to update comments on a document they don't + have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + client.force_login(user) + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_update_comment_authenticated_admin_or_owner_can_update_any_comment(role): + """ + Authenticated users should be able to update comments on a document they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test") + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.body == "other content" + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_update_comment_authenticated_admin_or_owner_can_update_own_comment(role): + """ + Authenticated users should be able to update comments on a document they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, body="test", user=user) + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/", + {"body": "other content"}, + ) + assert response.status_code == 200 + + comment.refresh_from_db() + assert comment.body == "other content" + + +# Delete comment + + +def test_delete_comment_anonymous_user_public_document(): + """Anonymous users should not be allowed to delete comments on a public document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +def test_delete_comment_anonymous_user_non_accessible_document(): + """Anonymous users should not be allowed to delete comments on a non-accessible document.""" + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 401 + + +def test_delete_comment_authenticated_user_accessible_document_own_comment(): + """Authenticated users should be able to delete comments on an accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, user=user) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +def test_delete_comment_authenticated_user_accessible_document_not_own_comment(): + """Authenticated users should not be able to delete comments on an accessible document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.COMMENTER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_delete_comment_authenticated_user_admin_or_owner_can_delete_any_comment(role): + """Authenticated users should be able to delete comments on a document they have access to.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_delete_comment_authenticated_user_admin_or_owner_can_delete_own_comment(role): + """Authenticated users should be able to delete comments on a document they have access to.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread, user=user) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 204 + + +def test_delete_comment_authenticated_user_not_enough_access(): + """ + Authenticated users should not be able to delete comments on a document they don't + have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.LinkRoleChoices.READER)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/comments/{comment.id!s}/" + ) + assert response.status_code == 403 + + +# Create reaction + + +@pytest.mark.parametrize("link_role", models.LinkRoleChoices.values) +def test_create_reaction_anonymous_user_public_document(link_role): + """No matter the link_role, an anonymous user can not react to a comment.""" + + document = factories.DocumentFactory(link_reach="public", link_role=link_role) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 401 + + +def test_create_reaction_authenticated_user_public_document(): + """ + Authenticated users should not be able to reaction to a comment on a public document with + link_role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +def test_create_reaction_authenticated_user_accessible_public_document(): + """ + Authenticated users should be able to react to a comment on a public document. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", link_role=models.LinkRoleChoices.COMMENTER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + +def test_create_reaction_authenticated_user_connected_document_link_role_reader(): + """ + Authenticated users should not be able to react to a comment on a connected document + with link_role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", + [ + role + for role in models.LinkRoleChoices.values + if role != models.LinkRoleChoices.READER + ], +) +def test_create_reaction_authenticated_user_connected_document(link_role): + """ + Authenticated users should be able to react to a comment on a connected document. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", link_role=link_role + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + +def test_create_reaction_authenticated_user_restricted_accessible_document(): + """ + Authenticated users should not be able to react to a comment on a restricted accessible document + they don't have access to. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted") + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +def test_create_reaction_authenticated_user_restricted_accessible_document_role_reader(): + """ + Authenticated users should not be able to react to a comment on a restricted accessible + document with role reader. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", link_role=models.LinkRoleChoices.READER + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_create_reaction_authenticated_user_restricted_accessible_document_role_commenter( + role, +): + """ + Authenticated users should be able to react to a comment on a restricted accessible document + with role commenter. + """ + user = factories.UserFactory() + document = factories.DocumentFactory(link_reach="restricted", users=[(user, role)]) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 201 + + assert models.Reaction.objects.filter( + comment=comment, emoji="test", users__in=[user] + ).exists() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": "test"}, + ) + assert response.status_code == 400 + assert response.json() == {"user_already_reacted": True} + + +# Delete reaction + + +def test_delete_reaction_not_owned_by_the_current_user(): + """ + Users should not be able to delete reactions not owned by the current user. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + reaction = factories.ReactionFactory(comment=comment) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": reaction.emoji}, + ) + assert response.status_code == 404 + + +def test_delete_reaction_owned_by_the_current_user(): + """ + Users should not be able to delete reactions not owned by the current user. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", users=[(user, models.RoleChoices.ADMIN)] + ) + thread = factories.ThreadFactory(document=document) + comment = factories.CommentFactory(thread=thread) + reaction = factories.ReactionFactory(comment=comment) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/" + f"comments/{comment.id!s}/reactions/", + {"emoji": reaction.emoji}, + ) + assert response.status_code == 404 + + reaction.refresh_from_db() + assert reaction.users.exists() diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index fa8b1e2eb6..7391b07149 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -36,6 +36,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": document.link_role in ["commenter", "editor"], "cors_proxy": True, "content": True, "descendants": True, @@ -46,8 +47,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, @@ -113,6 +114,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -220,6 +222,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, + "comment": document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -229,8 +232,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -304,6 +307,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, + "comment": grand_parent.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -497,10 +501,11 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", - "can_edit": access.role != "reader", + "can_edit": access.role not in ["reader", "commenter"], "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, + "comment": access.role != "reader", "descendants": True, "cors_proxy": True, "content": True, diff --git a/src/backend/core/tests/documents/test_api_documents_threads.py b/src/backend/core/tests/documents/test_api_documents_threads.py new file mode 100644 index 0000000000..cea0ae966f --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_threads.py @@ -0,0 +1,1226 @@ +"""Test Thread viewset.""" + +import pytest +from rest_framework.test import APIClient + +from core import factories, models + +pytestmark = pytest.mark.django_db + +# pylint: disable=too-many-lines + + +# Create + + +def test_api_documents_threads_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to create threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_public_document(link_role): + """ + Anonymous users should be allowed to create threads on public documents with commenter + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_restricted_document(): + """ + Authenticated users should not be allowed to create threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_restricted_document_editor(role): + """ + Authenticated users should be allowed to create threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to create threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 401 + + +def test_api_documents_threads_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to create threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_authenticated_document(link_role): + """ + Authenticated users should be allowed to create threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/", + { + "body": "test", + }, + ) + + assert response.status_code == 201 + thread = models.Thread.objects.first() + comment = thread.comments.first() + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "comments": [ + { + "id": str(comment.id), + "body": "test", + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": { + "full_name": user.full_name, + "short_name": user.short_name, + }, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# List + + +def test_api_documents_threads_list_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_list_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +def test_api_documents_threads_list_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_list_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", + [role for role in models.RoleChoices.values if role != models.RoleChoices.READER], +) +def test_api_documents_threads_list_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + factories.ThreadFactory.create_batch(3, document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/", + ) + assert response.status_code == 200 + assert response.json()["count"] == 3 + + +# Retrieve + + +def test_api_documents_threads_retrieve_public_document_link_role_reader(): + """ + Anonymous users should not be allowed to retrieve threads on public documents with reader + link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_public_document_link_role_higher_than_reader( + link_role, +): + """ + Anonymous users should be allowed to retrieve threads on public documents with commenter or + editor link_role. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +def test_api_documents_threads_retrieve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_authenticated_document(link_role): + """ + Authenticated users should be allowed to retrieve threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + } + + +def test_api_documents_threads_retrieve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to retrieve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_retrieve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to retrieve threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_retrieve_restricted_document_editor(role): + """ + Authenticated users should be allowed to retrieve threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": False, + "update": False, + "partial_update": False, + "resolve": False, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_retrieve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to retrieve + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + comment = factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.get( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 200 + content = response.json() + assert content == { + "id": str(thread.id), + "created_at": thread.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": thread.updated_at.isoformat().replace("+00:00", "Z"), + "creator": None, + "comments": [ + { + "id": str(comment.id), + "body": comment.body, + "created_at": comment.created_at.isoformat().replace("+00:00", "Z"), + "updated_at": comment.updated_at.isoformat().replace("+00:00", "Z"), + "user": None, + "reactions": [], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + }, + } + ], + "abilities": { + "destroy": True, + "update": True, + "partial_update": True, + "resolve": True, + "retrieve": True, + }, + "metadata": {}, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + } + + +# Destroy + + +def test_api_documents_threads_destroy_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to destroy threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_authenticated_document(link_role): + """ + Authenticated users should not be allowed to destroy threads on authenticated + documents with commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_destroy_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to destroy threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_destroy_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_destroy_restricted_document_editor(role): + """ + Authenticated users should not be allowed to destroy threads on restricted + documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_destroy_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to destroy + threads on restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/", + ) + assert response.status_code == 204 + assert not models.Thread.objects.filter(id=thread.id).exists() + + +# Resolve + + +def test_api_documents_threads_resolve_public_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on public documents. + """ + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_public_document_authenticated_user(): + """ + Authenticated users should not be allowed to resolve threads on public documents. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="public", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_authenticated_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on authenticated documents. + """ + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_authenticated_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on authenticated + documents with reader link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=models.LinkRoleChoices.READER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "link_role", [models.LinkRoleChoices.COMMENTER, models.LinkRoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_authenticated_document(link_role): + """ + Authenticated users should not be allowed to resolve threads on authenticated documents with + commenter or editor link_role. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="authenticated", + link_role=link_role, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +def test_api_documents_threads_resolve_restricted_document_anonymous_user(): + """ + Anonymous users should not be allowed to resolve threads on restricted documents. + """ + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.COMMENTER, + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 401 + + +def test_api_documents_threads_resolve_restricted_document_reader_role(): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + reader roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.READER, + users=[(user, models.LinkRoleChoices.READER)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize( + "role", [models.RoleChoices.COMMENTER, models.RoleChoices.EDITOR] +) +def test_api_documents_threads_resolve_restricted_document_editor(role): + """ + Authenticated users should not be allowed to resolve threads on restricted documents with + editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("role", [models.RoleChoices.ADMIN, models.RoleChoices.OWNER]) +def test_api_documents_threads_resolve_restricted_document_privileged_roles(role): + """ + Authenticated users with privileged roles should be allowed to resolve threads on + restricted documents with editor roles. + """ + user = factories.UserFactory() + document = factories.DocumentFactory( + link_reach="restricted", + link_role=models.LinkRoleChoices.EDITOR, + users=[(user, role)], + ) + + thread = factories.ThreadFactory(document=document, creator=None) + factories.CommentFactory(thread=thread, user=None) + + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/documents/{document.id!s}/threads/{thread.id!s}/resolve/", + ) + assert response.status_code == 204 + + # Verify thread is resolved + thread.refresh_from_db() + assert thread.resolved is True + assert thread.resolved_at is not None + assert thread.resolved_by == user diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 98a218acc2..cc32c09f95 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -81,6 +81,7 @@ def test_api_documents_trashbin_format(): "collaboration_auth": False, "descendants": False, "cors_proxy": False, + "comment": False, "content": False, "destroy": False, "duplicate": False, @@ -88,8 +89,8 @@ def test_api_documents_trashbin_format(): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, diff --git a/src/backend/core/tests/test_models_comment.py b/src/backend/core/tests/test_models_comment.py new file mode 100644 index 0000000000..7ff8cc8760 --- /dev/null +++ b/src/backend/core/tests/test_models_comment.py @@ -0,0 +1,283 @@ +"""Test the comment model.""" + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest + +from core import factories +from core.models import LinkReachChoices, LinkRoleChoices, RoleChoices + +pytestmark = pytest.mark.django_db + + +@pytest.mark.parametrize( + "role,can_comment", + [ + (LinkRoleChoices.READER, False), + (LinkRoleChoices.COMMENTER, True), + (LinkRoleChoices.EDITOR, True), + ], +) +def test_comment_get_abilities_anonymous_user_public_document(role, can_comment): + """Anonymous users cannot comment on a document.""" + document = factories.DocumentFactory( + link_role=role, link_reach=LinkReachChoices.PUBLIC + ) + comment = factories.CommentFactory(thread__document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_reach", [LinkReachChoices.RESTRICTED, LinkReachChoices.AUTHENTICATED] +) +def test_comment_get_abilities_anonymous_user_restricted_document(link_reach): + """Anonymous users cannot comment on a restricted document.""" + document = factories.DocumentFactory(link_reach=link_reach) + comment = factories.CommentFactory(thread__document=document) + user = AnonymousUser() + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": False, + "retrieve": False, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader(link_role, link_reach, can_comment): + """Readers cannot comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory(thread__document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": can_comment, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach,can_comment", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC, True), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED, False), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED, False), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED, True), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED, True), + ], +) +def test_comment_get_abilities_user_reader_own_comment( + link_role, link_reach, can_comment +): + """User with reader role on a document has all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.READER)] + ) + comment = factories.CommentFactory( + thread__document=document, user=user if can_comment else None + ) + + assert comment.get_abilities(user) == { + "destroy": can_comment, + "update": can_comment, + "partial_update": can_comment, + "reactions": can_comment, + "retrieve": can_comment, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commenter(link_role, link_reach): + """Commenters can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTER)], + ) + comment = factories.CommentFactory(thread__document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_commenter_own_comment(link_role, link_reach): + """Commenters have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, + link_reach=link_reach, + users=[(user, RoleChoices.COMMENTER)], + ) + comment = factories.CommentFactory(thread__document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor(link_role, link_reach): + """Editors can comment on a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(thread__document=document) + + assert comment.get_abilities(user) == { + "destroy": False, + "update": False, + "partial_update": False, + "reactions": True, + "retrieve": True, + } + + +@pytest.mark.parametrize( + "link_role,link_reach", + [ + (LinkRoleChoices.READER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.COMMENTER, LinkReachChoices.PUBLIC), + (LinkRoleChoices.EDITOR, LinkReachChoices.PUBLIC), + (LinkRoleChoices.READER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.EDITOR, LinkReachChoices.RESTRICTED), + (LinkRoleChoices.READER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.COMMENTER, LinkReachChoices.AUTHENTICATED), + (LinkRoleChoices.EDITOR, LinkReachChoices.AUTHENTICATED), + ], +) +def test_comment_get_abilities_user_editor_own_comment(link_role, link_reach): + """Editors have all accesses to its own comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory( + link_role=link_role, link_reach=link_reach, users=[(user, RoleChoices.EDITOR)] + ) + comment = factories.CommentFactory(thread__document=document, user=user) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_admin(): + """Admins have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.ADMIN)]) + comment = factories.CommentFactory( + thread__document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + } + + +def test_comment_get_abilities_user_owner(): + """Owners have all accesses to a comment.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, RoleChoices.OWNER)]) + comment = factories.CommentFactory( + thread__document=document, user=random.choice([user, None]) + ) + + assert comment.get_abilities(user) == { + "destroy": True, + "update": True, + "partial_update": True, + "reactions": True, + "retrieve": True, + } diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index 2fa88cf1fb..b8c3e93dd6 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -166,7 +166,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_last_on_child( "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -183,7 +183,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -200,7 +200,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -217,7 +217,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -234,7 +234,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator", "owner"], + "set_role_to": ["reader", "commenter", "editor", "administrator", "owner"], } @@ -271,7 +271,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } @@ -288,7 +288,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } @@ -305,7 +305,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["reader", "editor", "administrator"], + "set_role_to": ["reader", "commenter", "editor", "administrator"], } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 69236b6e96..91b8abf9ba 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -134,10 +134,13 @@ def test_models_documents_soft_delete(depth): [ (True, "restricted", "reader"), (True, "restricted", "editor"), + (True, "restricted", "commenter"), (False, "restricted", "reader"), (False, "restricted", "editor"), + (False, "restricted", "commenter"), (False, "authenticated", "reader"), (False, "authenticated", "editor"), + (False, "authenticated", "commenter"), ], ) def test_models_documents_get_abilities_forbidden( @@ -165,6 +168,7 @@ def test_models_documents_get_abilities_forbidden( "destroy": False, "duplicate": False, "favorite": False, + "comment": False, "invite_owner": False, "mask": False, "media_auth": False, @@ -172,8 +176,8 @@ def test_models_documents_get_abilities_forbidden( "move": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "partial_update": False, @@ -223,6 +227,7 @@ def test_models_documents_get_abilities_reader( "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": False, "descendants": True, "cors_proxy": True, "content": True, @@ -232,8 +237,78 @@ def test_models_documents_get_abilities_reader( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], + "restricted": None, + }, + "mask": is_authenticated, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": False, + "restore": False, + "retrieve": True, + "tree": True, + "update": False, + "versions_destroy": False, + "versions_list": False, + "versions_retrieve": False, + } + nb_queries = 1 if is_authenticated else 0 + with django_assert_num_queries(nb_queries): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key not in ["link_select_options", "ancestors_links_definition"] + ) + + +@override_settings( + AI_ALLOW_REACH_FROM=random.choice(["public", "authenticated", "restricted"]) +) +@pytest.mark.parametrize( + "is_authenticated,reach", + [ + (True, "public"), + (False, "public"), + (True, "authenticated"), + ], +) +def test_models_documents_get_abilities_commenter( + is_authenticated, reach, django_assert_num_queries +): + """ + Check abilities returned for a document giving commenter role to link holders + i.e anonymous users or authenticated users who have no specific role on the document. + """ + document = factories.DocumentFactory(link_reach=reach, link_role="commenter") + user = factories.UserFactory() if is_authenticated else AnonymousUser() + expected_abilities = { + "accesses_manage": False, + "accesses_view": False, + "ai_transform": False, + "ai_translate": False, + "attachment_upload": False, + "can_edit": False, + "children_create": False, + "children_list": True, + "collaboration_auth": True, + "comment": True, + "content": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": is_authenticated, + "favorite": is_authenticated, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -289,6 +364,7 @@ def test_models_documents_get_abilities_editor( "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "content": True, @@ -298,8 +374,8 @@ def test_models_documents_get_abilities_editor( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": is_authenticated, @@ -344,6 +420,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "content": True, @@ -353,8 +430,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "invite_owner": True, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -385,6 +462,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": False, "children_list": False, "collaboration_auth": False, + "comment": False, "descendants": False, "cors_proxy": False, "content": False, @@ -394,8 +472,8 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": False, @@ -430,6 +508,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "content": True, @@ -439,8 +518,8 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "invite_owner": False, "link_configuration": True, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -485,6 +564,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "comment": True, "descendants": True, "cors_proxy": True, "content": True, @@ -494,8 +574,8 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -547,6 +627,8 @@ def test_models_documents_get_abilities_reader_user( "children_create": access_from_link, "children_list": True, "collaboration_auth": True, + "comment": document.link_reach != "restricted" + and document.link_role in ["commenter", "editor"], "descendants": True, "cors_proxy": True, "content": True, @@ -556,8 +638,73 @@ def test_models_documents_get_abilities_reader_user( "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], + "restricted": None, + }, + "mask": True, + "media_auth": True, + "media_check": True, + "move": False, + "partial_update": access_from_link, + "restore": False, + "retrieve": True, + "tree": True, + "update": access_from_link, + "versions_destroy": False, + "versions_list": True, + "versions_retrieve": True, + } + + with override_settings(AI_ALLOW_REACH_FROM=ai_access_setting): + with django_assert_num_queries(1): + assert document.get_abilities(user) == expected_abilities + + document.soft_delete() + document.refresh_from_db() + assert all( + value is False + for key, value in document.get_abilities(user).items() + if key not in ["link_select_options", "ancestors_links_definition"] + ) + + +@pytest.mark.parametrize("ai_access_setting", ["public", "authenticated", "restricted"]) +def test_models_documents_get_abilities_commenter_user( + ai_access_setting, django_assert_num_queries +): + """Check abilities returned for the commenter of a document.""" + user = factories.UserFactory() + document = factories.DocumentFactory(users=[(user, "commenter")]) + + access_from_link = ( + document.link_reach != "restricted" and document.link_role == "editor" + ) + + expected_abilities = { + "accesses_manage": False, + "accesses_view": True, + # If you get your editor rights from the link role and not your access role + # You should not access AI if it's restricted to users with specific access + "ai_transform": access_from_link and ai_access_setting != "restricted", + "ai_translate": access_from_link and ai_access_setting != "restricted", + "attachment_upload": access_from_link, + "can_edit": access_from_link, + "children_create": access_from_link, + "children_list": True, + "collaboration_auth": True, + "comment": True, + "content": True, + "descendants": True, + "cors_proxy": True, + "destroy": False, + "duplicate": True, + "favorite": True, + "invite_owner": False, + "link_configuration": False, + "link_select_options": { + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -607,6 +754,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "children_create": False, "children_list": True, "collaboration_auth": True, + "comment": False, "descendants": True, "cors_proxy": True, "content": True, @@ -616,8 +764,8 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "invite_owner": False, "link_configuration": False, "link_select_options": { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], "restricted": None, }, "mask": True, @@ -1320,7 +1468,14 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", "reader", { - "public": ["reader", "editor"], + "public": ["reader", "commenter", "editor"], + }, + ), + ( + "public", + "commenter", + { + "public": ["commenter", "editor"], }, ), ("public", "editor", {"public": ["editor"]}), @@ -1328,8 +1483,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "authenticated", "reader", { - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], + }, + ), + ( + "authenticated", + "commenter", + { + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1342,8 +1505,17 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "reader", { "restricted": None, - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["reader", "commenter", "editor"], + "public": ["reader", "commenter", "editor"], + }, + ), + ( + "restricted", + "commenter", + { + "restricted": None, + "authenticated": ["commenter", "editor"], + "public": ["commenter", "editor"], }, ), ( @@ -1360,15 +1532,15 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "public", None, { - "public": ["reader", "editor"], + "public": ["reader", "commenter", "editor"], }, ), ( None, "reader", { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), @@ -1376,8 +1548,8 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): None, None, { - "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], + "public": ["reader", "commenter", "editor"], + "authenticated": ["reader", "commenter", "editor"], "restricted": None, }, ), diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 2ad8b00395..b843c89171 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -26,13 +26,24 @@ viewsets.InvitationViewset, basename="invitations", ) - +document_related_router.register( + "threads", + viewsets.ThreadViewSet, + basename="threads", +) document_related_router.register( "ask-for-access", viewsets.DocumentAskForAccessViewSet, basename="ask_for_access", ) +thread_related_router = DefaultRouter() +thread_related_router.register( + "comments", + viewsets.CommentViewSet, + basename="comments", +) + # - Routes nested under a template template_related_router = DefaultRouter() @@ -54,6 +65,10 @@ r"^documents/(?P[0-9a-z-]*)/", include(document_related_router.urls), ), + re_path( + r"^documents/(?P[0-9a-z-]*)/threads/(?P[0-9a-z-]*)/", + include(thread_related_router.urls), + ), re_path( r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts new file mode 100644 index 0000000000..ec37100da7 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -0,0 +1,295 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common'; +import { writeInEditor } from './utils-editor'; +import { + addNewMember, + connectOtherUserToDoc, + updateRoleUser, + updateShareLink, +} from './utils-share'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Comments', () => { + test('it checks comments with 2 users in real time', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1); + + // We share the doc with another user + const otherBrowserName = getOtherBrowserName(browserName); + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Administrator', otherBrowserName); + + await expect( + page + .getByRole('listbox', { name: 'Suggestions' }) + .getByText(new RegExp(otherBrowserName)), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + // We add a comment with the first user + const editor = await writeInEditor({ page, text: 'Hello World' }); + await editor.getByText('Hello').selectText(); + await page.getByRole('button', { name: 'Comment' }).click(); + + const thread = page.locator('.bn-thread'); + await thread.getByRole('paragraph').first().fill('This is a comment'); + await thread.locator('[data-test="save"]').click(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + await editor.getByText('Hello').click(); + + await thread.getByText('This is a comment').first().hover(); + + // We add a reaction with the first user + await thread.locator('[data-test="addreaction"]').first().click(); + await thread.getByRole('button', { name: '👍' }).click(); + + await expect( + thread.getByRole('img', { name: 'E2E Chromium' }).first(), + ).toBeVisible(); + await expect(thread.getByText('This is a comment').first()).toBeVisible(); + await expect(thread.getByText(`E2E ${browserName}`).first()).toBeVisible(); + await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1'); + + const urlCommentDoc = page.url(); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + otherBrowserName, + docUrl: urlCommentDoc, + docTitle, + }); + + const otherEditor = otherPage.locator('.ProseMirror'); + await otherEditor.getByText('Hello').click(); + const otherThread = otherPage.locator('.bn-thread'); + + await otherThread.getByText('This is a comment').first().hover(); + await otherThread.locator('[data-test="addreaction"]').first().click(); + await otherThread.getByRole('button', { name: '👍' }).click(); + + // We check that the comment made by the first user is visible for the second user + await expect( + otherThread.getByText('This is a comment').first(), + ).toBeVisible(); + await expect( + otherThread.getByText(`E2E ${browserName}`).first(), + ).toBeVisible(); + await expect(otherThread.locator('.bn-comment-reaction')).toHaveText('👍2'); + + // We add a comment with the second user + await otherThread + .getByRole('paragraph') + .last() + .fill('This is a comment from the other user'); + await otherThread.locator('[data-test="save"]').click(); + + // We check that the second user can see the comment he just made + await expect( + otherThread.getByRole('img', { name: `E2E ${otherBrowserName}` }).first(), + ).toBeVisible(); + await expect( + otherThread.getByText('This is a comment from the other user').first(), + ).toBeVisible(); + await expect( + otherThread.getByText(`E2E ${otherBrowserName}`).first(), + ).toBeVisible(); + + // We check that the first user can see the comment made by the second user in real time + await expect( + thread.getByText('This is a comment from the other user').first(), + ).toBeVisible(); + await expect( + thread.getByText(`E2E ${otherBrowserName}`).first(), + ).toBeVisible(); + + await cleanup(); + }); + + test('it checks the comments interactions', async ({ page, browserName }) => { + await createDoc(page, 'comment-interaction', browserName, 1); + + // Checks add react reaction + const editor = page.locator('.ProseMirror'); + await editor.locator('.bn-block-outer').last().fill('Hello World'); + await editor.getByText('Hello').selectText(); + await page.getByRole('button', { name: 'Comment' }).click(); + + const thread = page.locator('.bn-thread'); + await thread.getByRole('paragraph').first().fill('This is a comment'); + await thread.locator('[data-test="save"]').click(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + // Check background color changed + await expect(editor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + await editor.getByText('Hello').click(); + + await thread.getByText('This is a comment').first().hover(); + + // We add a reaction with the first user + await thread.locator('[data-test="addreaction"]').first().click(); + await thread.getByRole('button', { name: '👍' }).click(); + + await expect(thread.locator('.bn-comment-reaction')).toHaveText('👍1'); + + // Edit Comment + await thread.getByText('This is a comment').first().hover(); + await thread.locator('[data-test="moreactions"]').first().click(); + await thread.getByRole('menuitem', { name: 'Edit comment' }).click(); + const commentEditor = thread.getByText('This is a comment').first(); + await commentEditor.fill('This is an edited comment'); + const saveBtn = thread.getByRole('button', { name: 'Save' }); + await saveBtn.click(); + await expect(saveBtn).toBeHidden(); + await expect( + thread.getByText('This is an edited comment').first(), + ).toBeVisible(); + await expect(thread.getByText('This is a comment').first()).toBeHidden(); + + // Add second comment + await thread.getByRole('paragraph').last().fill('This is a second comment'); + await thread.getByRole('button', { name: 'Save' }).click(); + await expect( + thread.getByText('This is an edited comment').first(), + ).toBeVisible(); + await expect( + thread.getByText('This is a second comment').first(), + ).toBeVisible(); + + // Delete second comment + await thread.getByText('This is a second comment').first().hover(); + await thread.locator('[data-test="moreactions"]').first().click(); + await thread.getByRole('menuitem', { name: 'Delete comment' }).click(); + await expect( + thread.getByText('This is a second comment').first(), + ).toBeHidden(); + + // Resolve thread + await thread.getByText('This is an edited comment').first().hover(); + await thread.locator('[data-test="resolve"]').click(); + await expect(thread).toBeHidden(); + await expect(editor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + }); + + test('it checks the comments abilities', async ({ page, browserName }) => { + test.slow(); + + const [docTitle] = await createDoc(page, 'comment-doc', browserName, 1); + + // We share the doc with another user + const otherBrowserName = getOtherBrowserName(browserName); + + // Add a new member with editor role + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Editor', otherBrowserName); + + await expect( + page + .getByRole('listbox', { name: 'Suggestions' }) + .getByText(new RegExp(otherBrowserName)), + ).toBeVisible(); + + const urlCommentDoc = page.url(); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + otherBrowserName, + docUrl: urlCommentDoc, + docTitle, + }); + + const otherEditor = await writeInEditor({ + page: otherPage, + text: 'Hello, I can edit the document', + }); + await expect( + otherEditor.getByText('Hello, I can edit the document'), + ).toBeVisible(); + await otherEditor.getByText('Hello').selectText(); + await otherPage.getByRole('button', { name: 'Comment' }).click(); + const otherThread = otherPage.locator('.bn-thread'); + await otherThread + .getByRole('paragraph') + .first() + .fill('I can add a comment'); + await otherThread.locator('[data-test="save"]').click(); + await expect( + otherThread.getByText('I can add a comment').first(), + ).toBeHidden(); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + + // We change the role of the second user to reader + await updateRoleUser(page, 'Reader', `user.test@${otherBrowserName}.test`); + + // With the reader role, the second user cannot see comments + await otherPage.reload(); + await verifyDocName(otherPage, docTitle); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(0, 0, 0, 0)', + ); + await otherEditor.getByText('Hello').click(); + await expect(otherThread).toBeHidden(); + await otherEditor.getByText('Hello').selectText(); + await expect( + otherPage.getByRole('button', { name: 'Comment' }), + ).toBeHidden(); + + await otherPage.reload(); + + // Change the link role of the doc to set it in commenting mode + await updateShareLink(page, 'Public', 'Editing'); + + // Anonymous user can see and add comments + await otherPage.getByRole('button', { name: 'Logout' }).click(); + + await otherPage.goto(urlCommentDoc); + + await verifyDocName(otherPage, docTitle); + + await expect(otherEditor.getByText('Hello')).toHaveCSS( + 'background-color', + 'rgba(237, 180, 0, 0.4)', + ); + await otherEditor.getByText('Hello').click(); + await expect( + otherThread.getByText('I can add a comment').first(), + ).toBeVisible(); + + await otherThread + .locator('.ProseMirror.bn-editor[contenteditable="true"]') + .getByRole('paragraph') + .first() + .fill('Comment by anonymous user'); + await otherThread.locator('[data-test="save"]').click(); + + await expect( + otherThread.getByText('Comment by anonymous user').first(), + ).toBeVisible(); + + await expect( + otherThread.getByRole('img', { name: `Anonymous` }).first(), + ).toBeVisible(); + + await otherThread.getByText('Comment by anonymous user').first().hover(); + await expect(otherThread.locator('[data-test="moreactions"]')).toBeHidden(); + + await cleanup(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index c82623679f..49038642d1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -70,6 +70,14 @@ export const keyCloakSignIn = async ( await page.click('button[type="submit"]', { force: true }); }; +export const getOtherBrowserName = (browserName: BrowserName) => { + const otherBrowserName = BROWSERS.find((b) => b !== browserName); + if (!otherBrowserName) { + throw new Error('No alternative browser found'); + } + return otherBrowserName; +}; + export const randomName = (name: string, browserName: string, length: number) => Array.from({ length }, (_el, index) => { return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`; @@ -125,7 +133,9 @@ export const verifyDocName = async (page: Page, docName: string) => { try { await expect( page.getByRole('textbox', { name: 'Document title' }), - ).toContainText(docName); + ).toContainText(docName, { + timeout: 1000, + }); } catch { await expect(page.getByRole('heading', { name: docName })).toBeVisible(); } diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts index d1305f2f38..b24dbaeb4e 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-share.ts @@ -1,8 +1,8 @@ import { Page, chromium, expect } from '@playwright/test'; import { - BROWSERS, BrowserName, + getOtherBrowserName, keyCloakSignIn, verifyDocName, } from './utils-common'; @@ -14,7 +14,7 @@ export type LinkRole = 'Reading' | 'Editing'; export const addNewMember = async ( page: Page, index: number, - role: 'Administrator' | 'Owner' | 'Editor' | 'Reader', + role: Role, fillText = 'user.test', ) => { const responsePromiseSearchUser = page.waitForResponse( @@ -88,21 +88,30 @@ export const updateRoleUser = async ( * @param docTitle The title of the document (optional). * @returns An object containing the other browser, context, and page. */ +type ConnectOtherUserToDocParams = { + docUrl: string; + docTitle?: string; + withoutSignIn?: boolean; +} & ( + | { + otherBrowserName: BrowserName; + browserName?: never; + } + | { + browserName: BrowserName; + otherBrowserName?: never; + } +); + export const connectOtherUserToDoc = async ({ browserName, docUrl, docTitle, + otherBrowserName: _otherBrowserName, withoutSignIn, -}: { - browserName: BrowserName; - docUrl: string; - docTitle?: string; - withoutSignIn?: boolean; -}) => { - const otherBrowserName = BROWSERS.find((b) => b !== browserName); - if (!otherBrowserName) { - throw new Error('No alternative browser found'); - } +}: ConnectOtherUserToDocParams) => { + const otherBrowserName = + _otherBrowserName || getOtherBrowserName(browserName); const otherBrowser = await chromium.launch({ headless: true }); const otherContext = await otherBrowser.newContext({ diff --git a/src/frontend/apps/impress/cunningham.ts b/src/frontend/apps/impress/cunningham.ts index 10b51205b2..5accadd33c 100644 --- a/src/frontend/apps/impress/cunningham.ts +++ b/src/frontend/apps/impress/cunningham.ts @@ -98,8 +98,8 @@ const dsfrTheme = { }, font: { families: { - base: 'Marianne', - accent: 'Marianne', + base: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif', }, }, }, diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 078e9348ce..5394c0091a 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -19,14 +19,14 @@ }, "dependencies": { "@ag-media/react-pdf-table": "2.0.3", - "@blocknote/code-block": "0.41.1", - "@blocknote/core": "0.41.1", - "@blocknote/mantine": "0.41.1", - "@blocknote/react": "0.41.1", - "@blocknote/xl-docx-exporter": "0.41.1", - "@blocknote/xl-multi-column": "0.41.1", - "@blocknote/xl-odt-exporter": "0.41.1", - "@blocknote/xl-pdf-exporter": "0.41.1", + "@blocknote/code-block": "0.42.0", + "@blocknote/core": "0.42.0", + "@blocknote/mantine": "0.42.0", + "@blocknote/react": "0.42.0", + "@blocknote/xl-docx-exporter": "0.42.0", + "@blocknote/xl-multi-column": "0.42.0", + "@blocknote/xl-odt-exporter": "0.42.0", + "@blocknote/xl-pdf-exporter": "0.42.0", "@dnd-kit/core": "6.3.1", "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css index 3b1c544def..8bfbc0977b 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.css @@ -556,8 +556,10 @@ --c--theme--logo--widthHeader: 110px; --c--theme--logo--widthFooter: 220px; --c--theme--logo--alt: gouvernement logo; - --c--theme--font--families--base: marianne; - --c--theme--font--families--accent: marianne; + --c--theme--font--families--base: + marianne, inter, roboto flex variable, sans-serif; + --c--theme--font--families--accent: + marianne, inter, roboto flex variable, sans-serif; --c--components--la-gaufre: true; --c--components--home-proconnect: true; --c--components--favicon--ico: /assets/favicon-dsfr.ico; diff --git a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts index f0ebb07ea5..6261deb30a 100644 --- a/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts +++ b/src/frontend/apps/impress/src/cunningham/cunningham-tokens.ts @@ -436,7 +436,12 @@ export const tokens = { widthFooter: '220px', alt: 'Gouvernement Logo', }, - font: { families: { base: 'Marianne', accent: 'Marianne' } }, + font: { + families: { + base: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + accent: 'Marianne, Inter, Roboto Flex Variable, sans-serif', + }, + }, }, components: { 'la-gaufre': true, diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 680329d1cb..75a46581cf 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -13,3 +13,5 @@ export interface User { short_name: string; language?: string; } + +export type UserLight = Pick; diff --git a/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx new file mode 100644 index 0000000000..44c183f0e8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/AvatarSvg.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { Box, BoxType } from '@/components'; + +type AvatarSvgProps = { + initials: string; + background: string; + fontFamily?: string; +} & BoxType; + +export const AvatarSvg: React.FC = ({ + initials, + background, + fontFamily, + ...props +}) => ( + + + + {initials} + + +); diff --git a/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx new file mode 100644 index 0000000000..9bf836c5de --- /dev/null +++ b/src/frontend/apps/impress/src/features/auth/components/UserAvatar.tsx @@ -0,0 +1,70 @@ +import { renderToStaticMarkup } from 'react-dom/server'; + +import { tokens } from '@/cunningham'; + +import { AvatarSvg } from './AvatarSvg'; + +const colors = tokens.themes.default.theme.colors; + +const avatarsColors = [ + colors['blue-500'], + colors['brown-500'], + colors['cyan-500'], + colors['gold-500'], + colors['green-500'], + colors['olive-500'], + colors['orange-500'], + colors['pink-500'], + colors['purple-500'], + colors['yellow-500'], +]; + +const getColorFromName = (name: string) => { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return avatarsColors[Math.abs(hash) % avatarsColors.length]; +}; + +const getInitialFromName = (name: string) => { + const splitName = name?.split(' '); + return (splitName[0]?.charAt(0) || '?') + (splitName?.[1]?.charAt(0) || ''); +}; + +type UserAvatarProps = { + fullName?: string; + background?: string; +}; + +export const UserAvatar = ({ fullName, background }: UserAvatarProps) => { + const name = fullName?.trim() || '?'; + + return ( + + ); +}; + +export const avatarUrlFromName = ( + fullName?: string, + fontFamily?: string, +): string => { + const name = fullName?.trim() || '?'; + const initials = getInitialFromName(name).toUpperCase(); + const background = getColorFromName(name); + + const svgMarkup = renderToStaticMarkup( + , + ); + + return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup)}`; +}; diff --git a/src/frontend/apps/impress/src/features/auth/components/index.ts b/src/frontend/apps/impress/src/features/auth/components/index.ts index 17f3a9058a..26ebaf2e8b 100644 --- a/src/frontend/apps/impress/src/features/auth/components/index.ts +++ b/src/frontend/apps/impress/src/features/auth/components/index.ts @@ -1,2 +1,3 @@ export * from './Auth'; export * from './ButtonLogin'; +export * from './UserAvatar'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index eee27c2241..8c0728c787 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -12,13 +12,16 @@ import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; import { HocuspocusProvider } from '@hocuspocus/provider'; -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; import { Doc, useProviderStore } from '@/docs/doc-management'; -import { useAuth } from '@/features/auth'; +import { avatarUrlFromName, useAuth } from '@/features/auth'; +import { useResponsiveStore } from '@/stores'; import { useHeadings, @@ -34,6 +37,7 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; +import { cssComments, useComments } from './comments/'; import { AccessibleImageBlock, CalloutBlock, @@ -49,15 +53,56 @@ import XLMultiColumn from './xl-multi-column'; const multiColumnLocales = XLMultiColumn?.locales; const withMultiColumn = XLMultiColumn?.withMultiColumn; +// Patch video/file/audio/image blocks to have a valid number default for previewWidth +// This fixes the ProseMirror error: "No value supplied for attribute previewWidth" +// BlockNote's default blocks use `undefined` which causes runtime errors +const patchedVideoBlock = { + ...defaultBlockSpecs.video, + config: { + ...defaultBlockSpecs.video.config, + propSchema: { + ...defaultBlockSpecs.video.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, +}; + +const patchedAudioBlock = { + ...defaultBlockSpecs.audio, + config: { + ...defaultBlockSpecs.audio.config, + propSchema: { + ...defaultBlockSpecs.audio.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, +}; + +const patchedImageBlock = () => { + const imageSpec = AccessibleImageBlock(); + return { + ...imageSpec, + config: { + ...imageSpec.config, + propSchema: { + ...imageSpec.config.propSchema, + previewWidth: { default: 512, type: 'number' as const }, + }, + }, + }; +}; + const baseBlockNoteSchema = withPageBreak( BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + audio: patchedAudioBlock, callout: CalloutBlock(), codeBlock: createCodeBlockSpec(codeBlockOptions), - image: AccessibleImageBlock(), + image: patchedImageBlock(), pdf: PdfBlock(), uploadLoader: UploadLoaderBlock(), + video: patchedVideoBlock, }, inlineContentSpecs: { ...defaultInlineContentSpecs, @@ -79,8 +124,11 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { user } = useAuth(); const { setEditor } = useEditorStore(); const { t } = useTranslation(); + const { themeTokens } = useCunninghamTheme(); + const { isDesktop } = useResponsiveStore(); const { isSynced: isConnectedToCollabServer } = useProviderStore(); const refEditorContainer = useRef(null); + const canSeeComment = doc.abilities.comment && isDesktop; useSaveDoc(doc.id, provider.document, isConnectedToCollabServer); const { i18n } = useTranslation(); @@ -88,16 +136,25 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { uploadFile, errorAttachment } = useUploadFile(doc.id); - const collabName = user?.full_name || user?.email || t('Anonymous'); + const collabName = user?.full_name || user?.email; + const cursorName = collabName || t('Anonymous'); const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity'; + const threadStore = useComments(doc.id, canSeeComment, user); + + const currentUserAvatarUrl = useMemo(() => { + if (canSeeComment) { + return avatarUrlFromName(collabName, themeTokens?.font?.families?.base); + } + }, [canSeeComment, collabName, themeTokens?.font?.families?.base]); + const editor: DocsBlockNoteEditor = useCreateBlockNote( { collaboration: { provider: provider, fragment: provider.document.getXmlFragment('document-store'), user: { - name: collabName, + name: cursorName, color: randomColor(), }, /** @@ -138,11 +195,28 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, showCursorLabels: showCursorLabels as 'always' | 'activity', }, + comments: { threadStore }, dictionary: { ...locales[lang as keyof typeof locales], multi_column: multiColumnLocales?.[lang as keyof typeof multiColumnLocales], }, + resolveUsers: async (userIds) => { + return Promise.resolve( + userIds.map((encodedURIUserId) => { + const fullName = decodeURIComponent(encodedURIUserId); + + return { + id: encodedURIUserId, + username: fullName || t('Anonymous'), + avatarUrl: avatarUrlFromName( + fullName, + themeTokens?.font?.families?.base, + ), + }; + }), + ); + }, tables: { splitCells: true, cellBackgroundColor: true, @@ -152,7 +226,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { uploadFile, schema: blockNoteSchema, }, - [collabName, lang, provider, uploadFile], + [cursorName, lang, provider, uploadFile, threadStore], ); useHeadings(editor); @@ -170,7 +244,13 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }, [setEditor, editor]); return ( - + {errorAttachment && ( { /> )} - @@ -196,11 +277,17 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { }; interface BlockNoteReaderProps { + docId: Doc['id']; initialContent: Y.XmlFragment; } -export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { +export const BlockNoteReader = ({ + docId, + initialContent, +}: BlockNoteReaderProps) => { + const { user } = useAuth(); const { setEditor } = useEditorStore(); + const threadStore = useComments(docId, false, user); const { t } = useTranslation(); const editor = useCreateBlockNote( { @@ -213,6 +300,10 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { provider: undefined, }, schema: blockNoteSchema, + comments: { threadStore }, + resolveUsers: async () => { + return Promise.resolve([]); + }, }, [initialContent], ); @@ -228,14 +319,21 @@ export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => { useHeadings(editor); return ( - + ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx index 6b7a8181b5..b88e09c7a4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/BlockNoteToolbar.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { useConfig } from '@/core/config/api'; +import { CommentToolbarButton } from '../comments/CommentToolbarButton'; import { getCalloutFormattingToolbarItems } from '../custom-blocks'; import { AIGroupButton } from './AIButton'; @@ -25,10 +26,12 @@ export const BlockNoteToolbar = () => { const { data: conf } = useConfig(); const toolbarItems = useMemo(() => { - const toolbarItems = getFormattingToolbarItems([ + let toolbarItems = getFormattingToolbarItems([ ...blockTypeSelectItems(dict), getCalloutFormattingToolbarItems(t), ]); + + // Find the index of the file download button const fileDownloadButtonIndex = toolbarItems.findIndex( (item) => typeof item === 'object' && @@ -36,6 +39,8 @@ export const BlockNoteToolbar = () => { 'key' in item && (item as { key: string }).key === 'fileDownloadButton', ); + + // Replace the default file download button with our custom FileDownloadButton if (fileDownloadButtonIndex !== -1) { toolbarItems.splice( fileDownloadButtonIndex, @@ -50,12 +55,22 @@ export const BlockNoteToolbar = () => { ); } + // Remove default Comment button + toolbarItems = toolbarItems.filter((item) => { + if (typeof item === 'object' && item !== null && 'key' in item) { + return item.key !== 'addCommentButton'; + } + return true; + }); + return toolbarItems; }, [dict, t]); const formattingToolbar = useCallback(() => { return ( + + {toolbarItems} {/* Extra button to do some AI powered actions */} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx index 75a7965d09..6555303cf6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/MarkdownButton.tsx @@ -6,6 +6,9 @@ import { import { forEach, isArray } from 'lodash'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Text } from '@/components'; type Block = { type: string; @@ -83,8 +86,18 @@ export function MarkdownButton() { mainTooltip={t('Convert Markdown')} onClick={handleConvertMarkdown} className="--docs--editor-markdown-button" - > - M - + label="M" + icon={ + + M + + } + /> ); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index a2d04ba298..56c4ea1fb2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -116,6 +116,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => { initialContent={provider.document.getXmlFragment( 'document-store', )} + docId={doc.id} /> ) : ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx new file mode 100644 index 0000000000..3e81a1fff5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentToolbarButton.tsx @@ -0,0 +1,81 @@ +import { useBlockNoteEditor, useComponentsContext } from '@blocknote/react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Icon } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { useDocStore } from '@/features/docs/doc-management'; + +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../../types'; + +export const CommentToolbarButton = () => { + const Components = useComponentsContext(); + const { currentDoc } = useDocStore(); + const { t } = useTranslation(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + + const hasActiveUnresolvedThread = editor._tiptapEditor.isActive('comment', { + orphan: false, + }); + + if ( + !editor.isEditable || + !Components || + !currentDoc?.abilities.comment || + hasActiveUnresolvedThread + ) { + return null; + } + + return ( + + { + editor.comments?.startPendingComment(); + }} + isDisabled={hasActiveUnresolvedThread} + > + + + {t('Comment')} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx new file mode 100644 index 0000000000..f09c20b255 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStore.tsx @@ -0,0 +1,569 @@ +import { CommentBody, ThreadStore } from '@blocknote/core/comments'; +import type { Awareness } from 'y-protocols/awareness'; + +import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { Doc } from '@/features/docs/doc-management'; + +import { useEditorStore } from '../../stores'; + +import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; +import { + ClientCommentData, + ClientThreadData, + ServerComment, + ServerReaction, + ServerThread, +} from './types'; + +type ServerThreadListResponse = APIList; + +export class DocsThreadStore extends ThreadStore { + protected static COMMENTS_PING = 'commentsPing'; + protected threads: Map = new Map(); + private subscribers = new Set< + (threads: Map) => void + >(); + private awareness?: Awareness; + private lastPingAt = 0; + private pingTimer?: ReturnType; + + constructor( + protected docId: Doc['id'], + awareness: Awareness | undefined, + protected docAuth: DocsThreadStoreAuth, + ) { + super(docAuth); + + if (docAuth.canSee) { + this.awareness = awareness; + this.awareness?.on('update', this.onAwarenessUpdate); + void this.refreshThreads(); + } + } + + public destroy() { + this.awareness?.off('update', this.onAwarenessUpdate); + if (this.pingTimer) { + clearTimeout(this.pingTimer); + } + } + + private onAwarenessUpdate = async ({ + added, + updated, + }: { + added: number[]; + updated: number[]; + }) => { + if (!this.awareness) { + return; + } + const states = this.awareness.getStates(); + const listClientIds = [...added, ...updated]; + for (const clientId of listClientIds) { + // Skip our own client ID + if (clientId === this.awareness.clientID) { + continue; + } + + const state = states.get(clientId) as + | { + [DocsThreadStore.COMMENTS_PING]?: { + at: number; + docId: string; + isResolving: boolean; + threadId: string; + }; + } + | undefined; + + const ping = state?.commentsPing; + + // Skip if no ping information is available + if (!ping) { + continue; + } + + // Skip if the document ID doesn't match + if (ping.docId !== this.docId) { + continue; + } + + // Skip if the ping timestamp is past + if (ping.at <= this.lastPingAt) { + continue; + } + + this.lastPingAt = ping.at; + + // If we know the threadId, schedule a targeted refresh. Otherwise, fall back to full refresh. + if (ping.threadId) { + await this.refreshThread(ping.threadId); + } else { + await this.refreshThreads(); + } + } + }; + + /** + * To ping the other clients for updates on a specific thread + * @param threadId + */ + private ping(threadId?: string) { + this.awareness?.setLocalStateField(DocsThreadStore.COMMENTS_PING, { + at: Date.now(), + docId: this.docId, + threadId, + }); + } + + /** + * Notifies all subscribers about the current thread state + */ + private notifySubscribers() { + // Always emit a new Map reference to help consumers detect changes + const threads = new Map(this.threads); + this.subscribers.forEach((cb) => { + try { + cb(threads); + } catch (e) { + console.warn('DocsThreadStore subscriber threw', e); + } + }); + } + + private upsertClientThreadData(thread: ClientThreadData) { + const next = new Map(this.threads); + next.set(thread.id, thread); + this.threads = next; + } + + private removeThread(threadId: string) { + const next = new Map(this.threads); + next.delete(threadId); + this.threads = next; + } + + /** + * To subscribe to thread updates + * @param cb + * @returns + */ + public subscribe(cb: (threads: Map) => void) { + if (!this.docAuth.canSee) { + return () => {}; + } + + this.subscribers.add(cb); + + // Emit initial state asynchronously to avoid running during editor init + setTimeout(() => { + if (this.subscribers.has(cb)) { + cb(this.getThreads()); + } + }, 0); + + return () => { + this.subscribers.delete(cb); + }; + } + + public addThreadToDocument = (options: { + threadId: string; + selection: { + prosemirror: { + head: number; + anchor: number; + }; + yjs: { + head: unknown; + anchor: unknown; + }; + }; + }) => { + const { threadId } = options; + const { editor } = useEditorStore.getState(); + + // Should not happen + if (!editor) { + console.warn('Editor to add thread not ready'); + return Promise.resolve(); + } + + editor._tiptapEditor + .chain() + .focus?.() + .setMark?.('comment', { orphan: false, threadId }) + .run?.(); + + return Promise.resolve(); + }; + + public createThread = async (options: { + initialComment: { + body: CommentBody; + metadata?: unknown; + }; + metadata?: unknown; + }) => { + const response = await fetchAPI(`documents/${this.docId}/threads/`, { + method: 'POST', + body: JSON.stringify({ + body: options.initialComment.body, + }), + }); + + if (!response.ok) { + throw new APIError( + 'Failed to create thread in document', + await errorCauses(response), + ); + } + + const thread = (await response.json()) as ServerThread; + const threadData: ClientThreadData = serverThreadToClientThread(thread); + this.upsertClientThreadData(threadData); + this.notifySubscribers(); + this.ping(threadData.id); + return threadData; + }; + + public getThread(threadId: string) { + const thread = this.threads.get(threadId); + if (!thread) { + throw new Error('Thread not found'); + } + + return thread; + } + + public getThreads(): Map { + if (!this.docAuth.canSee) { + return new Map(); + } + + return this.threads; + } + + public async refreshThread(threadId: string) { + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/`, + { method: 'GET' }, + ); + + // If not OK and 404, the thread might have been deleted but the + // thread modal is still open, so we close it to avoid side effects + if (response.status === 404) { + // use escape key event to close the thread modal + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + keyCode: 27, + bubbles: true, + cancelable: true, + }), + ); + + await this.refreshThreads(); + return; + } + + if (!response.ok) { + throw new APIError( + `Failed to fetch thread ${threadId}`, + await errorCauses(response), + ); + } + + const serverThread = (await response.json()) as ServerThread; + + const clientThread = serverThreadToClientThread(serverThread); + this.upsertClientThreadData(clientThread); + this.notifySubscribers(); + } + + public async refreshThreads(): Promise { + const response = await fetchAPI(`documents/${this.docId}/threads/`, { + method: 'GET', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to get threads in document', + await errorCauses(response), + ); + } + + const threads = (await response.json()) as ServerThreadListResponse; + const next = new Map(); + threads.results.forEach((thread) => { + const threadData: ClientThreadData = serverThreadToClientThread(thread); + next.set(thread.id, threadData); + }); + this.threads = next; + this.notifySubscribers(); + } + + public addComment = async (options: { + comment: { + body: CommentBody; + metadata?: unknown; + }; + threadId: string; + }) => { + const { threadId } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/`, + { + method: 'POST', + body: JSON.stringify({ + body: options.comment.body, + }), + }, + ); + + if (!response.ok) { + throw new APIError('Failed to add comment ', await errorCauses(response)); + } + + const comment = (await response.json()) as ServerComment; + + // Optimistically update local thread with new comment + const existing = this.threads.get(threadId); + if (existing) { + const updated: ClientThreadData = { + ...existing, + updatedAt: new Date(comment.updated_at || comment.created_at), + comments: [...existing.comments, serverCommentToClientComment(comment)], + }; + this.upsertClientThreadData(updated); + this.notifySubscribers(); + } else { + // Fallback to fetching the thread if we don't have it locally + await this.refreshThread(threadId); + } + this.ping(threadId); + return serverCommentToClientComment(comment); + }; + + public updateComment = async (options: { + comment: { + body: CommentBody; + metadata?: unknown; + }; + threadId: string; + commentId: string; + }) => { + const { threadId, commentId, comment } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/${commentId}/`, + { + method: 'PUT', + body: JSON.stringify({ + body: comment.body, + }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to add thread to document', + await errorCauses(response), + ); + } + + await this.refreshThread(threadId); + this.ping(threadId); + + return; + }; + + public deleteComment = async (options: { + threadId: string; + commentId: string; + softDelete?: boolean; + }) => { + const { threadId, commentId } = options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/comments/${commentId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete comment', + await errorCauses(response), + ); + } + + // Optimistically remove the comment locally if we have the thread + const existing = this.threads.get(threadId); + if (existing) { + const updated: ClientThreadData = { + ...existing, + updatedAt: new Date(), + comments: existing.comments.filter((c) => c.id !== commentId), + }; + this.upsertClientThreadData(updated); + this.notifySubscribers(); + } else { + // Fallback to fetching the thread + await this.refreshThread(threadId); + } + this.ping(threadId); + }; + + /** + * UI not implemented + * @param _options + */ + public deleteThread = async (_options: { threadId: string }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${_options.threadId}/`, + { + method: 'DELETE', + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete thread', + await errorCauses(response), + ); + } + + // Remove locally and notify; no need to refetch everything + this.removeThread(_options.threadId); + this.notifySubscribers(); + this.ping(_options.threadId); + }; + + public resolveThread = async (_options: { threadId: string }) => { + const { threadId } = _options; + + const response = await fetchAPI( + `documents/${this.docId}/threads/${threadId}/resolve/`, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to resolve thread', + await errorCauses(response), + ); + } + + await this.refreshThreads(); + this.ping(threadId); + }; + + /** + * Todo: Not implemented backend side + * @returns + * @throws + */ + public unresolveThread = async (_options: { threadId: string }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${_options.threadId}/unresolve/`, + { method: 'POST' }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to unresolve thread', + await errorCauses(response), + ); + } + + await this.refreshThread(_options.threadId); + this.ping(_options.threadId); + }; + + public addReaction = async (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`, + { + method: 'POST', + body: JSON.stringify({ emoji: options.emoji }), + }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to add reaction to comment', + await errorCauses(response), + ); + } + + await this.refreshThread(options.threadId); + this.notifySubscribers(); + this.ping(options.threadId); + }; + + public deleteReaction = async (options: { + threadId: string; + commentId: string; + emoji: string; + }) => { + const response = await fetchAPI( + `documents/${this.docId}/threads/${options.threadId}/comments/${options.commentId}/reactions/`, + { method: 'DELETE', body: JSON.stringify({ emoji: options.emoji }) }, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to delete reaction from comment', + await errorCauses(response), + ); + } + + await this.refreshThread(options.threadId); + this.notifySubscribers(); + this.ping(options.threadId); + }; +} + +const serverReactionToReactionData = (r: ServerReaction) => { + return { + emoji: r.emoji, + createdAt: new Date(r.created_at), + userIds: r.users?.map((user) => + encodeURIComponent(user.full_name || ''), + ) || [''], + }; +}; + +const serverCommentToClientComment = (c: ServerComment): ClientCommentData => ({ + type: 'comment', + id: c.id, + userId: encodeURIComponent(c.user?.full_name || ''), + body: c.body, + createdAt: new Date(c.created_at), + updatedAt: new Date(c.updated_at), + reactions: (c.reactions ?? []).map(serverReactionToReactionData), + metadata: { abilities: c.abilities }, +}); + +const serverThreadToClientThread = (t: ServerThread): ClientThreadData => ({ + type: 'thread', + id: t.id, + createdAt: new Date(t.created_at), + updatedAt: new Date(t.updated_at), + comments: (t.comments ?? []).map(serverCommentToClientComment), + resolved: t.resolved, + resolvedUpdatedAt: t.resolved_updated_at + ? new Date(t.resolved_updated_at) + : undefined, + resolvedBy: t.resolved_by || undefined, + metadata: { abilities: t.abilities, metadata: t.metadata }, +}); 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 new file mode 100644 index 0000000000..57f614813f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/DocsThreadStoreAuth.tsx @@ -0,0 +1,94 @@ +import { ThreadStoreAuth } from '@blocknote/core/comments'; + +import { ClientCommentData, ClientThreadData } from './types'; + +export class DocsThreadStoreAuth extends ThreadStoreAuth { + constructor( + private readonly userId: string, + public canSee: boolean, + ) { + super(); + } + + canCreateThread(): boolean { + return true; + } + + canAddComment(_thread: ClientThreadData): boolean { + return true; + } + + canUpdateComment(comment: ClientCommentData): boolean { + if ( + comment.metadata.abilities.partial_update && + comment.userId === this.userId + ) { + return true; + } + + return false; + } + + canDeleteComment(comment: ClientCommentData): boolean { + if (comment.metadata.abilities.destroy) { + return true; + } + + return false; + } + + canDeleteThread(thread: ClientThreadData): boolean { + if (thread.metadata.abilities.destroy) { + return true; + } + + return false; + } + + canResolveThread(thread: ClientThreadData): boolean { + if (thread.metadata.abilities.resolve) { + return true; + } + + return false; + } + + /** + * Not implemented backend side + * @param _thread + * @returns + */ + canUnresolveThread(_thread: ClientThreadData): boolean { + return false; + } + + canAddReaction(comment: ClientCommentData, emoji?: string): boolean { + if (!comment.metadata.abilities.reactions) { + return false; + } + + if (!emoji) { + return true; + } + + return !comment.reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.userIds.includes(this.userId), + ); + } + + canDeleteReaction(comment: ClientCommentData, emoji?: string): boolean { + if (!comment.metadata.abilities.reactions) { + return false; + } + + if (!emoji) { + return true; + } + + return comment.reactions.some( + (reaction) => + reaction.emoji === emoji && reaction.userIds.includes(this.userId), + ); + } +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts new file mode 100644 index 0000000000..99acd58df1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/index.ts @@ -0,0 +1,3 @@ +export * from './CommentToolbarButton'; +export * from './styles'; +export * from './useComments'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx new file mode 100644 index 0000000000..93055e9dc9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/styles.tsx @@ -0,0 +1,213 @@ +import { css } from 'styled-components'; + +export const cssComments = ( + canSeeComment: boolean, + currentUserAvatarUrl?: string, +) => css` + & .--docs--main-editor, + & .--docs--main-editor .ProseMirror { + // Comments marks in the editor + .bn-editor { + .bn-thread-mark:not([data-orphan='true']), + .bn-thread-mark-selected:not([data-orphan='true']) { + background: ${canSeeComment ? '#EDB40066' : 'transparent'}; + color: var(--c--theme--colors--greyscale-700); + } + } + + em-emoji-picker { + box-shadow: 0px 6px 18px 0px #00001229; + min-height: 420px; + } + + // Thread modal + .bn-thread { + width: 400px; + padding: 8px; + box-shadow: 0px 6px 18px 0px #00001229; + margin-left: 20px; + gap: 0; + + .bn-default-styles { + font-family: var(--c--theme--font--families--base); + } + + .bn-block { + font-size: 14px; + } + + .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before { + font-style: normal; + font-size: 14px; + } + + // Remove tooltip + *[role='tooltip'] { + display: none; + } + + .bn-thread-comments { + overflow: auto; + max-height: 500px; + } + + .bn-thread-comment { + padding: 8px; + + & .bn-editor { + padding-left: 32px; + .bn-inline-content { + color: var(--c--theme--colors--greyscale-700); + } + } + + // Emoji + & .bn-badge-group { + padding-left: 32px; + .bn-badge label { + padding: 0 4px; + background: none; + border: 1px solid var(--c--theme--colors--greyscale-300); + border-radius: 4px; + height: 24px; + } + } + + // Top bar (Name / Date / Actions) when actions displayed + &:has(.bn-comment-actions) { + & > .mantine-Group-root { + max-width: 70%; + right: 0.3rem !important; + top: 0.3rem !important; + } + } + + // Top bar (Name / Date / Actions) + & > .mantine-Group-root { + flex-wrap: nowrap; + max-width: 100%; + gap: 0.5rem; + + // Date + span.mantine-focus-auto { + display: inline-block; + } + + .bn-comment-actions { + background: transparent; + border: none; + + .mantine-Button-root { + background-color: transparent; + + &:hover { + background-color: var(--c--theme--colors--greyscale-100); + } + } + + button[role='menuitem'] svg { + color: var(--c--theme--colors--greyscale-600); + } + } + + & svg { + color: var(--c--theme--colors--info-600); + } + } + + // Actions button edit comment + .bn-container + .bn-comment-actions-wrapper { + .bn-comment-actions { + flex-direction: row-reverse; + background: none; + border: none; + gap: 0.4rem !important; + + & > button { + height: 24px; + padding-inline: 4px; + + &[data-test='save'] { + border: 1px solid var(--c--theme--colors--info-600); + background: var(--c--theme--colors--info-600); + color: white; + } + + &[data-test='cancel'] { + background: white; + border: 1px solid var(--c--theme--colors--greyscale-300); + color: var(--c--theme--colors--info-600); + } + } + } + } + } + + // Input to add a new comment + .bn-thread-composer, + &:has(> .bn-comment-editor + .bn-comment-actions-wrapper) { + padding: 0.5rem 8px; + flex-direction: row; + gap: 10px; + + .bn-container.bn-comment-editor { + min-width: 0; + } + + &::before { + content: ''; + width: 26px; + height: 26px; + flex: 0 0 26px; + background-image: ${currentUserAvatarUrl + ? `url("${currentUserAvatarUrl}")` + : 'none'}; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + } + } + + // Actions button send comment + .bn-thread-composer .bn-comment-actions-wrapper, + &:not(.selected) .bn-comment-actions-wrapper { + flex-basis: fit-content; + + .bn-action-toolbar.bn-comment-actions { + border: none; + + button { + font-size: 0; + background: var(--c--theme--colors--info-600); + width: 24px; + height: 24px; + padding: 0; + + &:disabled { + background: var(--c--theme--colors--greyscale-300); + } + + & .mantine-Button-label::before { + content: '🡡'; + font-size: 13px; + color: var(--c--theme--colors--greyscale-100); + } + } + } + } + + // Input first comment + &:not(.selected) { + gap: 0.5rem; + + .bn-container.bn-comment-editor { + min-width: 0; + + .ProseMirror.bn-editor { + cursor: text; + } + } + } + } + } +`; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts new file mode 100644 index 0000000000..be47816572 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/types.ts @@ -0,0 +1,55 @@ +import { CommentData, ThreadData } from '@blocknote/core/comments'; + +import { UserLight } from '@/features/auth'; + +export interface CommentAbilities { + destroy: boolean; + update: boolean; + partial_update: boolean; + retrieve: boolean; + reactions: boolean; +} +export interface ThreadAbilities { + destroy: boolean; + update: boolean; + partial_update: boolean; + retrieve: boolean; + resolve: boolean; +} + +export interface ServerReaction { + emoji: string; + created_at: string; + users: UserLight[] | null; +} + +export interface ServerComment { + id: string; + user: UserLight | null; + body: unknown; + created_at: string; + updated_at: string; + reactions: ServerReaction[]; + abilities: CommentAbilities; +} + +export interface ServerThread { + id: string; + created_at: string; + updated_at: string; + user: UserLight | null; + resolved: boolean; + resolved_updated_at: string | null; + resolved_by: string | null; + metadata: unknown; + comments: ServerComment[]; + abilities: ThreadAbilities; +} + +export type ClientCommentData = Omit & { + metadata: { abilities: CommentAbilities }; +}; + +export type ClientThreadData = Omit & { + metadata: { abilities: ThreadAbilities; metadata: unknown }; +}; 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 new file mode 100644 index 0000000000..99be3acfea --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/useComments.ts @@ -0,0 +1,33 @@ +import { useEffect, useMemo } from 'react'; + +import { User } from '@/features/auth'; +import { Doc, useProviderStore } from '@/features/docs/doc-management'; + +import { DocsThreadStore } from './DocsThreadStore'; +import { DocsThreadStoreAuth } from './DocsThreadStoreAuth'; + +export function useComments( + docId: Doc['id'], + canComment: boolean, + user: User | null | undefined, +) { + const { provider } = useProviderStore(); + const threadStore = useMemo(() => { + return new DocsThreadStore( + docId, + provider?.awareness ?? undefined, + new DocsThreadStoreAuth( + encodeURIComponent(user?.full_name || ''), + canComment, + ), + ); + }, [docId, canComment, provider?.awareness, user?.full_name]); + + useEffect(() => { + return () => { + threadStore?.destroy(); + }; + }, [threadStore]); + + return threadStore; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx index 9027ec69dc..15ad01f341 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -173,5 +173,4 @@ export const getCalloutFormattingToolbarItems = ( name: t('Callout'), type: 'callout', icon: () => , - isSelected: (block) => block.type === 'callout', }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx index ace3e738d7..6948aa9e98 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/PdfBlock.tsx @@ -36,7 +36,7 @@ type CreatePDFBlockConfig = BlockConfig< backgroundColor: { default: 'default' }; caption: { default: '' }; name: { default: '' }; - previewWidth: { default: undefined; type: 'number' }; + previewWidth: { default: 512; type: 'number' }; showPreview: { default: true }; textAlignment: { default: 'left' }; url: { default: '' }; @@ -117,7 +117,7 @@ export const PdfBlock = createReactBlockSpec( backgroundColor: { default: 'default' as const }, caption: { default: '' as const }, name: { default: '' as const }, - previewWidth: { default: undefined, type: 'number' }, + previewWidth: { default: 512, type: 'number' }, showPreview: { default: true }, textAlignment: { default: 'left' as const }, url: { default: '' as const }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx index 3bade52bc0..8315c425d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/styles.tsx @@ -132,6 +132,10 @@ export const cssEditor = css` .bn-block-outer:not([data-prev-depth-changed]):before { border-left: none; } + + .bn-toolbar { + max-width: 95vw; + } } & .bn-editor { diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx index 89ebb5ea7d..70ee7b137f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx @@ -23,8 +23,9 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping }); } } + return ( - + {exporter.transformInlineContent(block.content)} ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 1d1f607cec..890d874d29 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -39,5 +39,26 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { shading: { fill: 'DCDCDC' }, } : {}, + // If the color is not defined, we fall back to default colors + backgroundColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + shading: { + fill: + exporter.options.colors?.[val]?.background?.slice(1) || '#ffffff', + }, + }; + }, + // If the color is not defined, we fall back to default colors + textColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + color: exporter.options.colors?.[val]?.text?.slice(1) || '#3c3b38', + }; + }, }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 0d0c7cbd10..b18401034e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -39,5 +39,25 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { // that is not available in italics code: (enabled?: boolean) => enabled ? { fontFamily: 'Courier', backgroundColor: '#dcdcdc' } : {}, + // If the color is not defined, we fall back to default colors + textColor: (val, exporter) => { + if (!val) { + return {}; + } + + return { + color: exporter.options.colors?.[val]?.text || '#3c3b38', + }; + }, + // If the color is not defined, we fall back to default colors + backgroundColor: (val, exporter) => { + if (!val) { + return {}; + } + return { + backgroundColor: + exporter.options.colors?.[val]?.background || '#ffffff', + }; + }, }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index d786366e2d..3160a1a77c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -80,6 +80,7 @@ export interface Doc { children_create: boolean; children_list: boolean; collaboration_auth: boolean; + comment: boolean; destroy: boolean; duplicate: boolean; favorite: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx index 529626109d..1ff6f67aec 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx @@ -4,9 +4,7 @@ import { QuickSearchItemContentProps, } from '@/components/quick-search'; import { useCunninghamTheme } from '@/cunningham'; -import { User } from '@/features/auth'; - -import { UserAvatar } from './UserAvatar'; +import { User, UserAvatar } from '@/features/auth'; type Props = { user: User; @@ -36,7 +34,7 @@ export const SearchUserRow = ({ className="--docs--search-user-row" > { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - return avatarsColors[Math.abs(hash) % avatarsColors.length]; -}; - -type Props = { - user: User; - background?: string; -}; - -export const UserAvatar = ({ user, background }: Props) => { - const name = user.full_name || user.email || '?'; - const splitName = name?.split(' '); - - return ( - - {splitName[0]?.charAt(0)} - {splitName?.[1]?.charAt(0)} - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx index a7574a44d4..fc1f76f9c3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/DocVersionEditor.tsx @@ -77,7 +77,9 @@ export const DocVersionEditor = ({ return ( } - docEditor={} + docEditor={ + + } isDeletedDoc={false} readOnly={true} /> diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index e84415a72e..2fe688816b 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -188,6 +188,7 @@ export class ApiPlugin implements WorkboxPlugin { children_create: true, children_list: true, collaboration_auth: true, + comment: true, destroy: true, duplicate: true, favorite: true, diff --git a/src/frontend/package.json b/src/frontend/package.json index f617eee575..c4cc029279 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -36,8 +36,9 @@ "@types/react-dom": "19.2.2", "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", - "docx": "9.5.0", + "docx": "9.5.1", "eslint": "9.39.0", + "prosemirror-state": "1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "typescript": "5.9.3", diff --git a/src/frontend/servers/y-provider/package.json b/src/frontend/servers/y-provider/package.json index 38fd3b18f3..f90d11d7de 100644 --- a/src/frontend/servers/y-provider/package.json +++ b/src/frontend/servers/y-provider/package.json @@ -16,7 +16,7 @@ "node": ">=22" }, "dependencies": { - "@blocknote/server-util": "0.41.1", + "@blocknote/server-util": "0.42.0", "@hocuspocus/server": "3.4.0", "@sentry/node": "10.22.0", "@sentry/profiling-node": "10.22.0", @@ -30,7 +30,7 @@ "yjs": "*" }, "devDependencies": { - "@blocknote/core": "0.41.1", + "@blocknote/core": "0.42.0", "@hocuspocus/provider": "3.4.0", "@types/cors": "2.8.19", "@types/express": "5.0.5", diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 4ab8234f5e..f2e8c4bb8e 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -1070,49 +1070,49 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@blocknote/code-block@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.41.1.tgz#bfe0597d3a995b8a2a08307753ec858306781a2d" - integrity sha512-GrvRW0Q9Y6HHCC7tbA76YoGafZPwFqbJgflOVl/kj0h142v8NMH5dgtO/FR/8DOSdnoe9dSUBqUott5ATQo/qg== - dependencies: - "@blocknote/core" "0.41.1" - "@shikijs/core" "^3.2.1" - "@shikijs/engine-javascript" "^3.2.1" - "@shikijs/langs" "^3.2.1" - "@shikijs/langs-precompiled" "^3.2.1" - "@shikijs/themes" "^3.2.1" +"@blocknote/code-block@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/code-block/-/code-block-0.42.0.tgz#19340fdb3cc23174bea38d9c36a7d6b83e8c7505" + integrity sha512-N8f6jPVS7m95Cpxf9SJGScPw4bq2cjwl5KtyZAmR7HWMo7K5s+/1jp8SQBV2oLLQ7SVWxSBB3elDq5Y3RYiPaw== + dependencies: + "@blocknote/core" "0.42.0" + "@shikijs/core" "^3.13.0" + "@shikijs/engine-javascript" "^3.13.0" + "@shikijs/langs" "^3.13.0" + "@shikijs/langs-precompiled" "^3.13.0" + "@shikijs/themes" "^3.13.0" "@shikijs/types" "^3.13.0" -"@blocknote/core@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.41.1.tgz#63959297ced039874e5661138bfdffa43605f6ad" - integrity sha512-p/wxXzpl0/c9QwqXWcZ4KXzI+OjVzQOzSNaO5KrtDPDi7M1Bj6sc9L0+/V/8Wyo+XTY+tZOrtu6qCXVYIEJ/Rw== +"@blocknote/core@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.42.0.tgz#eba21388af2fd5dfecefa60585dd70ff37b1d000" + integrity sha512-V478pyJFTJxdKmVo2ICqKNVzuRNgBOK3Zg3Ut1AKozrOGJ2mB4h1esDncK01P/DSCLDro6TmSobOrVAF55w1uA== dependencies: "@emoji-mart/data" "^1.2.1" "@shikijs/types" "3.13.0" - "@tiptap/core" "^3.4.3" - "@tiptap/extension-bold" "^3" - "@tiptap/extension-code" "^3" - "@tiptap/extension-gapcursor" "^3" - "@tiptap/extension-history" "^3" - "@tiptap/extension-horizontal-rule" "^3" - "@tiptap/extension-italic" "^3" - "@tiptap/extension-link" "^3" - "@tiptap/extension-paragraph" "^3" - "@tiptap/extension-strike" "^3" - "@tiptap/extension-text" "^3" - "@tiptap/extension-underline" "^3" - "@tiptap/pm" "^3.4.3" + "@tiptap/core" "^3.10.2" + "@tiptap/extension-bold" "^3.7.2" + "@tiptap/extension-code" "^3.7.2" + "@tiptap/extension-gapcursor" "^3.7.2" + "@tiptap/extension-history" "^3.7.2" + "@tiptap/extension-horizontal-rule" "^3.7.2" + "@tiptap/extension-italic" "^3.7.2" + "@tiptap/extension-link" "^3.7.2" + "@tiptap/extension-paragraph" "^3.7.2" + "@tiptap/extension-strike" "^3.7.2" + "@tiptap/extension-text" "^3.7.2" + "@tiptap/extension-underline" "^3.7.2" + "@tiptap/pm" "^3.10.2" emoji-mart "^5.6.0" - fast-deep-equal "^3" + fast-deep-equal "^3.1.3" hast-util-from-dom "^5.0.1" prosemirror-dropcursor "^1.8.2" prosemirror-highlight "^0.13.0" - prosemirror-model "^1.25.3" - prosemirror-state "^1.4.3" - prosemirror-tables "^1.6.4" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.1" prosemirror-transform "^1.10.4" - prosemirror-view "^1.41.2" + prosemirror-view "^1.41.3" rehype-format "^5.0.1" rehype-parse "^9.0.1" rehype-remark "^10.0.1" @@ -1128,92 +1128,92 @@ y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/mantine@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.41.1.tgz#3bd2030d79376df5a3c44ec3cdf601eea50d9bdf" - integrity sha512-0geMa5zRd3d67xpDCGAclW4y0yQ8Hn0ldjzUz7ilB/9NpYt+f9Y5uuuaK4DwchwYuMmCLDrtjtAh/jfmdSnnjw== +"@blocknote/mantine@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.42.0.tgz#9b9bdb7463e4d1cb9d463d545691b36bdbd87e0f" + integrity sha512-N4sbfFm6mJaS9oRBOibyUUDdWDns1350m3CAUJXJzmTCp0ugOlVm+TPzMj2ENnALwkYEDtLWIDXrfen7XQ8r6Q== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - react-icons "^5.2.1" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + react-icons "^5.5.0" -"@blocknote/react@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.41.1.tgz#4f35f8314ad001903cf9793e7dc9673b05ec3344" - integrity sha512-W1lRcyjpgNOZzASIbdX/fvEQ3ZML7FzHyS2xA9CskxOPrGvxHWREn+vper/hkchlTZ4I2dTx/IWwGAECT+2AvA== +"@blocknote/react@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.42.0.tgz#c2099574c21bb55744a91436aff77032d44a6788" + integrity sha512-6o2lUdzQAffe20mkb8q+KnNZLQ5MjAMztnMV14I0eraSpdFfNt11msASbL6uYXTwLL8mLSGaMUojcR8mLv3u1w== dependencies: - "@blocknote/core" "0.41.1" + "@blocknote/core" "0.42.0" "@emoji-mart/data" "^1.2.1" "@floating-ui/react" "^0.27.16" - "@tiptap/core" "^3.4.3" - "@tiptap/pm" "^3.4.3" - "@tiptap/react" "^3.4.3" + "@tiptap/core" "^3.10.2" + "@tiptap/pm" "^3.10.2" + "@tiptap/react" "^3.10.2" emoji-mart "^5.6.0" lodash.merge "^4.6.2" - react-icons "^5.2.1" + react-icons "^5.5.0" -"@blocknote/server-util@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.41.1.tgz#db738ea33039a9dd54c0a92e9c3ed72bfa354cb3" - integrity sha512-xZaj/jwKq4rVdOxaNyBmJIJTZ0c8++Ttvy6Zp9W7B2XLxT9baGbsAtXCTra+lBHCf/XyqvA12UuuyB4KrA5bnQ== +"@blocknote/server-util@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/server-util/-/server-util-0.42.0.tgz#5048735228a770bf674dfd61af041a477241c849" + integrity sha512-cV0bwaT1xqYujbyUbg0TodSm1+CBsWFdnOO5oXdmKH5Tl4T1Cl/nJ+SUAxXZuJK3onl/yCRE2WbDeGjMUkn7Jw== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@tiptap/core" "^3.4.3" - "@tiptap/pm" "^3.4.3" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@tiptap/core" "^3.10.2" + "@tiptap/pm" "^3.10.2" jsdom "^25.0.1" y-prosemirror "^1.3.7" y-protocols "^1.0.6" yjs "^13.6.27" -"@blocknote/xl-docx-exporter@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.41.1.tgz#498f68c7eb9a42c2c3606f61d972c815f92cb0e5" - integrity sha512-dyMY/jcxTlZCKpV1ABve7me4gCZHQzkcY5sqzvZtbqmv0IslqD4xq06ZU9KYUoCLnrJTEuRXN9dxnaCp08ipRQ== +"@blocknote/xl-docx-exporter@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-docx-exporter/-/xl-docx-exporter-0.42.0.tgz#d9155d56c42de747663f0eac0a01f16c71801f9d" + integrity sha512-CPsaI39LZMTZCwy1anUP4J7ClcZNyLp2lpwf2IunDq9XNBqOweRKrt9IBXceC3/VIf/t80k3sl58rOtbZjt5lQ== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/xl-multi-column" "0.41.1" + "@blocknote/core" "0.42.0" + "@blocknote/xl-multi-column" "0.42.0" buffer "^6.0.3" - docx "^9.0.2" - image-meta "^0.2.1" - -"@blocknote/xl-multi-column@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.41.1.tgz#c2ab05c5cb6c1666c28e405689d6f85ec0a2cd0b" - integrity sha512-SAYyKLpvdWfiuWPRAg7eR9/XfyX51f1o8tptcKWwfCk0FBND+VmYK7IJuxq1N789wdm+msuxUedSZWzKiSErpA== - dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@tiptap/core" "^3.4.3" - prosemirror-model "^1.25.3" - prosemirror-state "^1.4.3" - prosemirror-tables "^1.3.7" + docx "^9.5.1" + image-meta "^0.2.2" + +"@blocknote/xl-multi-column@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-multi-column/-/xl-multi-column-0.42.0.tgz#1225fa7a2fa91e40c7b71a88a457c7af2cfcd3d7" + integrity sha512-qnJbDCaQblAnHMR9H9BGMEulBpDUdWPqtrWTwM51NFlDpmA1IdoNksYzI/gPo1WU6IAeYSpdW9W3iMVHuoI5tQ== + dependencies: + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@tiptap/core" "^3.10.2" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.1" prosemirror-transform "^1.10.4" - prosemirror-view "^1.41.2" - react-icons "^5.2.1" + prosemirror-view "^1.41.3" + react-icons "^5.5.0" -"@blocknote/xl-odt-exporter@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-odt-exporter/-/xl-odt-exporter-0.41.1.tgz#55be888d7b6158a6e352aab414cee12e1dcf4326" - integrity sha512-VAQC8isRoioK097yuFX0p6dIrwp/GyWInd4hDkux3gsGTMqdXRiLV42symC6+qEseukz+IbGqWvWGsgAplwkZQ== +"@blocknote/xl-odt-exporter@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-odt-exporter/-/xl-odt-exporter-0.42.0.tgz#7e6ef5cb42a2dcc713900d7b85f244cdbf6ceff1" + integrity sha512-vhevrHGmPLnaZNuwPzu9ec/4+EUZWTwQgiwVninuU2cYBbY8vG/+/nMEU3mWgIWR+BFnNqOLiv4kry2+cALmVA== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/xl-multi-column" "0.41.1" - "@zip.js/zip.js" "^2.7.57" + "@blocknote/core" "0.42.0" + "@blocknote/xl-multi-column" "0.42.0" + "@zip.js/zip.js" "^2.8.8" buffer "^6.0.3" - image-meta "^0.2.1" + image-meta "^0.2.2" -"@blocknote/xl-pdf-exporter@0.41.1": - version "0.41.1" - resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.41.1.tgz#b55c7e8c6a21ae069a42671b1391eab9d4119195" - integrity sha512-Ixhlm2iV9a82AroMvW7w2RTNp4H7aEkYO/ar6bcMTIZul2kmg06akkw+tQc3rH+CLYQTiFXr9iKwGdnLxoIDww== +"@blocknote/xl-pdf-exporter@0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@blocknote/xl-pdf-exporter/-/xl-pdf-exporter-0.42.0.tgz#ea9345e1b9c5c970ad3c1db142704d573743064a" + integrity sha512-h2iQNUptd3Pl79B8WRS+ZL73yhuDqQMXLW60GJq3nkPDw1osmWFZUN0Dg5UhYvflT8j0gS1wDNM1+2K52ou2lQ== dependencies: - "@blocknote/core" "0.41.1" - "@blocknote/react" "0.41.1" - "@blocknote/xl-multi-column" "0.41.1" + "@blocknote/core" "0.42.0" + "@blocknote/react" "0.42.0" + "@blocknote/xl-multi-column" "0.42.0" "@react-pdf/renderer" "^4.3.0" buffer "^6.0.3" - docx "^9.0.2" + docx "^9.5.1" "@cacheable/memoize@^2.0.3": version "2.0.3" @@ -4964,46 +4964,46 @@ unplugin "1.0.1" uuid "^9.0.0" -"@shikijs/core@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.13.0.tgz#73503364a1eb51b65cf904115c62fed7a47df596" - integrity sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA== +"@shikijs/core@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.15.0.tgz#eee251070b4e39b59e108266cbcd50c85d738d54" + integrity sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/vscode-textmate" "^10.0.2" "@types/hast" "^3.0.4" hast-util-to-html "^9.0.5" -"@shikijs/engine-javascript@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-3.13.0.tgz#d25cefdac378216a95fefdf0b3a560550393ea65" - integrity sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg== +"@shikijs/engine-javascript@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-3.15.0.tgz#478dd4feb3b4b7e91f148cc9e7ebc0b7de5fbb18" + integrity sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/vscode-textmate" "^10.0.2" oniguruma-to-es "^4.3.3" -"@shikijs/langs-precompiled@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/langs-precompiled/-/langs-precompiled-3.13.0.tgz#6ac03cc178fc246e0ddae386a8339b4bd5499c31" - integrity sha512-B2xmXar8IdCy2Gf+VtWmcv8tWpfeFPxPP3eKDa13dKshERbxHHVe0gCV+NrlcWbyVxBm22IUqqj7TIewJstNBQ== +"@shikijs/langs-precompiled@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/langs-precompiled/-/langs-precompiled-3.15.0.tgz#7ad88657a4658eba1c261f074d2eff12980f748e" + integrity sha512-APb/UJeT1FPttKYyi2qMsN9OtGSU14xXME9ecSjb9uNchxo5Kszw+BLufBS6I9/5SFaUDmKxunZV1OIm/Pe3ug== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" oniguruma-to-es "^4.3.3" -"@shikijs/langs@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.13.0.tgz#51a927c8089dffb2560ac8d7549297de9d081b91" - integrity sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ== +"@shikijs/langs@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-3.15.0.tgz#d8385a9ca66ce9923149c650336444b1d25fc248" + integrity sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" -"@shikijs/themes@^3.2.1": - version "3.13.0" - resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.13.0.tgz#ee92780f0580d4ffa8ed619b52c5eb4a95d012a3" - integrity sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg== +"@shikijs/themes@^3.13.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-3.15.0.tgz#6093a90191b89654045c72636ddd35c04273658f" + integrity sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ== dependencies: - "@shikijs/types" "3.13.0" + "@shikijs/types" "3.15.0" "@shikijs/types@3.13.0", "@shikijs/types@^3.13.0": version "3.13.0" @@ -5013,6 +5013,14 @@ "@shikijs/vscode-textmate" "^10.0.2" "@types/hast" "^3.0.4" +"@shikijs/types@3.15.0": + version "3.15.0" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-3.15.0.tgz#4e025b4dea98e1603243b1f00677854e07e5eda1" + integrity sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + "@shikijs/vscode-textmate@^10.0.2": version "10.0.2" resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" @@ -5253,79 +5261,79 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.7.2.tgz#f9333a9dce628d72c05f919abca1148896161338" - integrity sha512-fJwNpTx0aq4UU0HNkxPvPYfNBcTHQ/q5xBUdOB5Mgu6clwGES38jVsNNSudB8g53APUmJIS+2fJbkxl3V+0jww== +"@tiptap/core@^3.10.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.10.7.tgz#3e56d68d2a8f7e686b31261c720052a580d1d5c0" + integrity sha512-4rD3oHkXNOS6Fxm0mr+ECyq35iMFnnAXheIO+UsQbOexwTxn2yZ5Q1rQiFKcCf+p+rrg1yt8TtxQPM8VLWS+1g== -"@tiptap/extension-bold@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.7.2.tgz#cbc94eb7239ef36e1f5581f93e0001bac013c4c6" - integrity sha512-bwCn9lQEXnEi7LfIx3G/oaH4I0ZapAgrHzLCNJH/tNgRKVWym1H1Oa8PlkiFDbalWOdUkbgeAUqUaIB13k408Q== +"@tiptap/extension-bold@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.10.7.tgz#9811788760905753fd53cfdfc615d91765b50f87" + integrity sha512-NWjOIIZdxUSkWLQrEY4Tg60MzS6RGt/1aLnwTyFFzFFShzOmd/xzxp0fRS+p79ZKNcQa9OKgnrlS4xuRq8WOdQ== -"@tiptap/extension-bubble-menu@^3.7.2": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.7.2.tgz#c6cac8a2db5ef12d1fb12538413ec2ae578a0dad" - integrity sha512-rCJu/X7sZEYWkOwLO342JP06f4giVBECPzr/SzG/fQdAidPW96eilPk3L82w5j24kS9odTlxSLlFlIf6UZ2b9w== +"@tiptap/extension-bubble-menu@^3.10.7": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.10.7.tgz#0393b889a6ad29ab1b6ac08542d47cd8b05da626" + integrity sha512-ezsNpClKQ4Bq6R+Y/jGcmxhSBuYYOCGXV72yy3SlX1w6seA/I8h27ktWy9zAD2RPX560NzpZEyBjaASL3961sQ== dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-code@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.7.2.tgz#80b43d1590cff592c25b7552c3706b4f4fbbece3" - integrity sha512-J8FaCiKJJnHvQiPcbfbUtc5RNmGx/Gui/K5CDMPc17jhCiQ9JhR9idRPREV24Z2t7GujWX7LG6ZDDR82pSns+g== - -"@tiptap/extension-floating-menu@^3.7.2": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.7.2.tgz#927217d238b45695621be75398795974c3fa3737" - integrity sha512-g19ratrXlplYDS29VLQa1y/IM/ro0UFhSS4fQokiQKkazwnA1ZVnebjw8ERYg5lkMm/hiImqstpgdO0LtoivvQ== - -"@tiptap/extension-gapcursor@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.7.2.tgz#2e2a673a42c87854ecb989d2aebf213514e04d63" - integrity sha512-vCLo2dL2SfeWjh/gJKDiu0/fz6OF7obGTJvHg/yStkoUqlAEiwKoyHP/NXeTGYJMzZzUi0kY9DtTEJdGFvphuQ== - -"@tiptap/extension-history@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.7.2.tgz#4210b78a726ad6763b1cf1a506bac56e5dfafcff" - integrity sha512-iAGTUxAr7r+tQ/PtIG94jqTJLy/S/VwE43USfWzXCHvbLn60cPJJG7MTCZxYbd+ZuivZVhEhp3EbzCNmHxjp8A== - -"@tiptap/extension-horizontal-rule@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.7.2.tgz#5d8e9d70e7c6ba91b3e4d9a7e1693232c37ce161" - integrity sha512-pN+1hJAVVP3uqtpZ5Rm7z5XUB/NGprK6wExJ04xG117E4rTVcaEb1FnMILY3J3A5XbdC3vHX+cblR8mOl1PAMw== - -"@tiptap/extension-italic@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.7.2.tgz#8425d27a67e1d70172ebebcc0cfa7fd8afe5b50b" - integrity sha512-1tfF37LvKgA5hg09UBgOjdMLNRb1C6keIOBF0r5oHKeWPYOf4z3j5IU9PsFUoOn53XRMb1aiD/TNbGPyoT3Fyw== - -"@tiptap/extension-link@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.7.2.tgz#6a2f6b979eb86905c8776df12a71838fb5e2007c" - integrity sha512-9K54PxBiDSWAMfICqkb8jcQ6cL7vDAtjTk0zqBw4d+XuaUy0FC9QUdbx7r1Pkbf36K1/ApbvM9a7qpOirWk8Xw== +"@tiptap/extension-code@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.10.7.tgz#2dfa6b51fb269c2cc9ab5703dc2b3e0158787b60" + integrity sha512-POK3CCy29LoRI6JVvFRVAmH2G90a7pKJT8sbqOaX1WKmLLDt7drUxGgBNnz/cBXJQHPnXZgRq/P8ZQPISklT7Q== + +"@tiptap/extension-floating-menu@^3.10.7": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.10.7.tgz#d147fcde8961453c0b3d50693a7f1cc98345dccd" + integrity sha512-yuTIGDbx0Q2IWOUrkhVQ/i1fU0Qi+8fCS8jkGB34/+3nbhtqXNYfFajpeaU9rkcCJqXH4aiFJdSGy44kCnYP2g== + +"@tiptap/extension-gapcursor@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.10.7.tgz#99a0a1e09bf0d0ae84b7106c3fbd60000f2cde5d" + integrity sha512-1VDNX+4ZCKxuoj6nRTZDwHjPYhuSdELYYCSfxscojlwexPxCLcgqOt71xdgnQXW5Hv6ACT4OrGGYcGTupudOHg== + +"@tiptap/extension-history@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.10.7.tgz#2f2a6cf9b521d6c01e5febbe309009c5815cd847" + integrity sha512-QP0JZbA3jm3GY/VZNM3KC5bMkraj3GHglw1HAr1jcwIjwkvGS+Q6SmrfBTovTzT7y23QpZAPNKpToxy7X76C7A== + +"@tiptap/extension-horizontal-rule@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.10.7.tgz#733a011dc0bc674e0245a904a42f3ae3e00580ec" + integrity sha512-V9uWb341QUBDDbR3aoSs3Sx0PQQaKwZ/ESVEE03El9rkIrf8g5K82x8/M0nvSOvGobt6oRyI/rgbj196YQuXiQ== + +"@tiptap/extension-italic@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.10.7.tgz#161e1af8b6b0e39edbb37d9d0990b62369dffc28" + integrity sha512-1CQgHNm51xDyZI188f5xKLcUIjRS+2cyZgS9XwKwIU/3QOsiKsNC+cBc4VmN3aR0A01NjK0ch0MjeKkPPWUt5A== + +"@tiptap/extension-link@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.10.7.tgz#2c7aee94df95dccb2ca40b8dfcc7e88427616b13" + integrity sha512-AIgrtveTQ5QyRpcic2MVSuv9aOaN0n+swdZPvi8XREZX/uf1SU4dYU7p0dNChhcn53GGPDNVRTQXX4YdEAZFQQ== dependencies: linkifyjs "^4.3.2" -"@tiptap/extension-paragraph@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.7.2.tgz#4e9a84d36c96a92cccc7c6bec9f4f744beef413c" - integrity sha512-HmDuAixTcvP4A/v6OLkh/C6nB86i7/DRNswBf/Udak8TgWUIcSUK0iActxxm5+B3MZTSf3U87JzyI6IeuElLIQ== +"@tiptap/extension-paragraph@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.10.7.tgz#f751b4c8c7991747a3f5899fa39a7c197fbd92bc" + integrity sha512-53+nCxNaKcmeqQ+aWrSauEWywuWPp8qkUTOO2rHlpmM+rk/1bv3IZePKQ2JtHZzYCeRd3xOC33kl60HE7EwakQ== -"@tiptap/extension-strike@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.7.2.tgz#fd82bc48e47413bb80caa53b6fb9d90c4edeff3c" - integrity sha512-I1G+4vZbCBTpAMmyVwaO8cLBJgXEf1DyEzc0B+HhTJiBa9qA9OKgRQEGFgisxu1kggjbzB6+d0+taHfjsZC1SQ== +"@tiptap/extension-strike@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.10.7.tgz#5bf95f4d0b568b9ad8927e6097df1c7cb4b6994c" + integrity sha512-pZMdQhChv59jsahvmjiJjSTPM05J6EHAX/GPdA9w8xSKy73899MhIhWJ7yt2CJEPjwn3ixnomIPhMjxBkizv+g== -"@tiptap/extension-text@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.7.2.tgz#fbaf60768a2d3ef77f51e5e070614bdebf371be2" - integrity sha512-sKaeGYNP1+bAe2rvmzWLW5qH9DsSFOJlOUEOFchR0OX0rC7bbGS6/KuyAq0w6UkL+cMJnDyAbv3KeD2WEA192w== +"@tiptap/extension-text@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.10.7.tgz#3a9f4f104362012e84da4f2751f52c02ec385106" + integrity sha512-b7Rjil/uqiabWnRHyd1P84rWD2XRyZZSrmIAO9mDMD/jB2bE+f7rDJcHG76GF03UicDhEEEf2/8mz0dMLa6mUA== -"@tiptap/extension-underline@^3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.7.2.tgz#e3831c7211501ba4b8bbd1f588c6b86f351fa13a" - integrity sha512-GDpUZllTD7uIdHjTzYJ6i4jUgCeviW40SCpLVVv1xH0gj1t1xu0Rnxmk+bXkF2XNe8jPXkMCgYNr6DR6eO8roQ== +"@tiptap/extension-underline@^3.7.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.10.7.tgz#371a00dbed2c8e5a5bb9f94c11a0e14b92f83d7e" + integrity sha512-yBL81xdbjT5Y7acoBqWpnH/SoH3bpgqaLvJBG3NNk+mdLB5HjBWTlPLKjvjQV0HRN5bZ+RJWeiRnQk1ahcfmQA== "@tiptap/extensions@*": version "3.7.2" @@ -5337,10 +5345,10 @@ resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.10.1.tgz#7faab67917a779a77ec89f588ad5fb7670b4dbff" integrity sha512-tZZ1IGIcch4ezuoid3iPSirh0s2GQuSKY6ceWRJCVeZ2gT2LsN3i10tqfidcYrsmyQRMuM7QUfRmH5HOKJZ73Q== -"@tiptap/pm@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.7.2.tgz#28953dc6e310445250b5b858286186c5ada14a2f" - integrity sha512-i2fvXDapwo/TWfHM6STYEbkYyF3qyfN6KEBKPrleX/Z80G5bLxom0gB79TsjLNxTLi6mdf0vTHgAcXMG1avc2g== +"@tiptap/pm@^3.10.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.10.7.tgz#d7028d96824e555f78e1b4490107e9db72eb53b4" + integrity sha512-/iiurioqSukJk6CrEtfRpdOEafDybyVPToAllgn7i2XcusXSxJSX+K0GUndMUwVR+UqVOCyMYBTRTnE0hdQqgA== dependencies: prosemirror-changeset "^2.3.0" prosemirror-collab "^1.3.1" @@ -5361,17 +5369,17 @@ prosemirror-transform "^1.10.2" prosemirror-view "^1.38.1" -"@tiptap/react@^3.4.3": - version "3.7.2" - resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.7.2.tgz#bf12f0f33f76407abf06b8530cb9461335a1e659" - integrity sha512-tka4ioSmsGI4TyGZ7jAUoIw8t8DVjr1It0B38vZVLqg8M/ZFgR1NkF50TJ6qAkhy8Uz12AO50so0v79tV2pmEA== +"@tiptap/react@^3.10.2": + version "3.10.7" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.10.7.tgz#cfd2ade1c6db316136bac46457c394a1e09a80c7" + integrity sha512-hhKj62zvs/mSu5HlcmZDRFHVHCjJ6v6/7vB45MTAziP+cZ0+CEbEh2rnGNRNwooumWwm5pWdkVqI1efp7GtnUA== dependencies: "@types/use-sync-external-store" "^0.0.6" fast-deep-equal "^3.1.3" use-sync-external-store "^1.4.0" optionalDependencies: - "@tiptap/extension-bubble-menu" "^3.7.2" - "@tiptap/extension-floating-menu" "^3.7.2" + "@tiptap/extension-bubble-menu" "^3.10.7" + "@tiptap/extension-floating-menu" "^3.10.7" "@trysound/sax@0.2.0": version "0.2.0" @@ -5664,7 +5672,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.0", "@types/node@^22.7.5": +"@types/node@*", "@types/node@22.10.7", "@types/node@24.10.0", "@types/node@^24.0.1": version "24.10.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f" integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A== @@ -6264,7 +6272,7 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -"@zip.js/zip.js@^2.7.57": +"@zip.js/zip.js@^2.8.8": version "2.8.10" resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.8.10.tgz#98a0cc7fdef9d6e227236271af412db02b18a5b2" integrity sha512-WVywWK8HSttmFFYSih7lUjjaV4zGzMxy992y0tHrZY4Wf9x/uNBA/XJ50RvfGjuuJKti4yueEHA2ol2pOq6VDg== @@ -7681,12 +7689,12 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -docx@*, docx@9.5.0, docx@^9.0.2: - version "9.5.0" - resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.0.tgz#586990c4ecf1c7e83290529997b33f2c029bbe68" - integrity sha512-WZggg9vVujFcTyyzfIVBBIxlCk51QvhLWl87wtI2zuBdz8C8C0mpRhEVwA2DZd7dXyY0AVejcEVDT9vn7Xm9FA== +docx@*, docx@9.5.1, docx@^9.5.1: + version "9.5.1" + resolved "https://registry.yarnpkg.com/docx/-/docx-9.5.1.tgz#325c9c45dccf052e5780515d6068e80fdee81960" + integrity sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ== dependencies: - "@types/node" "^22.7.5" + "@types/node" "^24.0.1" hash.js "^1.1.7" jszip "^3.10.1" nanoid "^5.1.3" @@ -8466,7 +8474,7 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -fast-deep-equal@^3, fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -9501,7 +9509,7 @@ ignore@^7.0.0, ignore@^7.0.5: resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -image-meta@^0.2.1: +image-meta@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/image-meta/-/image-meta-0.2.2.tgz#a88dbdf1983d7c23a80c3e71d3b8acdb5379f5e0" integrity sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA== @@ -12143,7 +12151,7 @@ prosemirror-menu@^1.2.4: prosemirror-history "^1.0.0" prosemirror-state "^1.0.0" -prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.3: +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0, prosemirror-model@^1.25.4: version "1.25.4" resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.4.tgz#8ebfbe29ecbee9e5e2e4048c4fe8e363fcd56e7c" integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA== @@ -12166,7 +12174,7 @@ prosemirror-schema-list@^1.5.0: prosemirror-state "^1.0.0" prosemirror-transform "^1.7.3" -prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3: +prosemirror-state@1.4.3, prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4: version "1.4.3" resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080" integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q== @@ -12175,7 +12183,7 @@ prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3: prosemirror-transform "^1.0.0" prosemirror-view "^1.27.0" -prosemirror-tables@^1.3.7, prosemirror-tables@^1.6.4: +prosemirror-tables@^1.6.4, prosemirror-tables@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz#896a234e3e18240b629b747a871369dae78c8a9a" integrity sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug== @@ -12201,7 +12209,7 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor dependencies: prosemirror-model "^1.21.0" -prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.2: +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1, prosemirror-view@^1.41.3: version "1.41.3" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.3.tgz#753a37ebe172a3e313ad2c3d85496f9ed1b2c256" integrity sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ== @@ -12527,7 +12535,7 @@ react-i18next@16.2.3: html-parse-stringify "^3.0.1" use-sync-external-store "^1.6.0" -react-icons@^5.2.1: +react-icons@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.5.0.tgz#8aa25d3543ff84231685d3331164c00299cdfaf2" integrity sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==