diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index e9244eb5a..2947554fa 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -18,7 +18,7 @@ A clear and concise description of what you expected to happen (or code). 3. And then the bug happens! **Environment** -- Drive version: +- Message version: - Platform: **Possible Solution** diff --git a/Makefile b/Makefile index 6569f40f6..3a12748bb 100644 --- a/Makefile +++ b/Makefile @@ -393,9 +393,10 @@ showmigrations: ## show all migrations for the messages project. @$(MANAGE_DB) showmigrations .PHONY: showmigrations -superuser: ## Create an admin superuser with password "admin" +superuser: ## Create an admin superuser with password "admin" and promote user1 as superuser @echo "$(BOLD)Creating a Django superuser$(RESET)" @$(MANAGE_DB) createsuperuser --email admin@admin.local --password admin + @$(MANAGE_DB) createsuperuser --email user1@example.local --password user1 .PHONY: superuser shell-back: ## open a shell in the backend container diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index bf476e98c..c4d291a0f 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -422,11 +422,21 @@ class ThreadAccessInline(admin.TabularInline): readonly_fields = ("read_at", "starred_at") +class ThreadEventInline(admin.TabularInline): + """Inline class for the ThreadEvent model""" + + model = models.ThreadEvent + autocomplete_fields = ("author", "channel") + raw_id_fields = ("message",) + readonly_fields = ("created_at",) + extra = 0 + + @admin.register(models.Thread) class ThreadAdmin(admin.ModelAdmin): """Admin class for the Thread model""" - inlines = [ThreadAccessInline] + inlines = [ThreadAccessInline, ThreadEventInline] list_display = ( "id", "subject", diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 748a6e011..1f2df39b1 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -5453,6 +5453,340 @@ } } }, + "/api/v1.0/threads/{thread_id}/events/": { + "get": { + "operationId": "threads_events_list", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + } + }, + "description": "" + } + } + }, + "post": { + "operationId": "threads_events_create", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + } + }, + "/api/v1.0/threads/{thread_id}/events/{id}/": { + "get": { + "operationId": "threads_events_retrieve", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "put": { + "operationId": "threads_events_update", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ThreadEventRequest" + } + } + }, + "required": true + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "patch": { + "operationId": "threads_events_partial_update", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchedThreadEventRequest" + } + }, + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/PatchedThreadEventRequest" + } + } + } + }, + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThreadEvent" + } + } + }, + "description": "" + } + } + }, + "delete": { + "operationId": "threads_events_destroy", + "description": "ViewSet for ThreadEvent model.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + } + } + } + }, + "/api/v1.0/threads/{thread_id}/users/": { + "get": { + "operationId": "threads_users_list", + "description": "List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess).", + "parameters": [ + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-users" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserWithoutAbilities" + } + } + } + }, + "description": "" + } + } + } + }, "/api/v1.0/threads/stats/": { "get": { "operationId": "threads_stats_retrieve", @@ -6821,7 +7155,7 @@ "type": "object", "properties": { "type": { - "$ref": "#/components/schemas/TypeEnum" + "$ref": "#/components/schemas/MailboxAdminCreateMetadataTypeEnum" }, "first_name": { "type": "string" @@ -6838,6 +7172,15 @@ "type" ] }, + "MailboxAdminCreateMetadataTypeEnum": { + "enum": [ + "personal", + "shared", + "redirect" + ], + "type": "string", + "description": "* `personal` - personal\n* `shared` - shared\n* `redirect` - redirect" + }, "MailboxAdminCreatePayloadRequest": { "type": "object", "properties": { @@ -7799,6 +8142,57 @@ } } }, + "PatchedThreadEventRequest": { + "type": "object", + "description": "Serialize thread event information.", + "properties": { + "type": { + "$ref": "#/components/schemas/ThreadEventTypeEnum" + }, + "message": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "nullable": true + }, + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "mentions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + ] + } + } + }, "ReadMessageTemplate": { "type": "object", "description": "Serialize message templates with dynamic body field inclusion.\n\nBody fields (html_body, text_body, raw_body) are only included when\nexplicitly requested via the ``?bodies=`` query parameter or the\n``body_fields`` keyword argument (for nested usage).\n\nAllowed values: ``raw``, ``html``, ``text`` (comma-separated).\nMapping: ``raw`` → ``raw_body``, ``html`` → ``html_body``, ``text`` → ``text_body``.\n\nWhen neither query param nor kwarg is provided, no body field is returned.", @@ -8321,6 +8715,170 @@ "editor" ] }, + "ThreadEvent": { + "type": "object", + "description": "Serialize thread event information.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "thread": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "readOnly": true + }, + "type": { + "$ref": "#/components/schemas/ThreadEventTypeEnum" + }, + "channel": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "readOnly": true, + "nullable": true + }, + "message": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "nullable": true + }, + "author": { + "allOf": [ + { + "$ref": "#/components/schemas/UserWithoutAbilities" + } + ], + "readOnly": true + }, + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "mentions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Created on", + "description": "date and time at which a record was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "readOnly": true, + "title": "Updated on", + "description": "date and time at which a record was last updated" + } + }, + "required": [ + "author", + "channel", + "created_at", + "data", + "id", + "thread", + "type", + "updated_at" + ] + }, + "ThreadEventRequest": { + "type": "object", + "description": "Serialize thread event information.", + "properties": { + "type": { + "$ref": "#/components/schemas/ThreadEventTypeEnum" + }, + "message": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID", + "nullable": true + }, + "data": { + "oneOf": [ + { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "mentions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "data", + "type" + ] + }, + "ThreadEventTypeEnum": { + "enum": [ + "im" + ], + "type": "string", + "description": "* `im` - Instant message" + }, "ThreadLabel": { "type": "object", "description": "Serializer to get labels details for a thread.", @@ -8434,15 +8992,6 @@ "slug" ] }, - "TypeEnum": { - "enum": [ - "personal", - "shared", - "redirect" - ], - "type": "string", - "description": "* `personal` - personal\n* `shared` - shared\n* `redirect` - redirect" - }, "UserWithAbilities": { "type": "object", "description": "Serialize users with abilities.\nAllow to have separated OpenAPI definition for users with and without abilities.", diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index ec5e2136a..814a3bffa 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -108,20 +108,46 @@ def has_permission(self, request, view): if not IsAuthenticated.has_permission(self, request, view): return False - # This check is primarily for LIST actions based on query params - mailbox_id = request.query_params.get("mailbox_id") # Used by Thread list - thread_id = request.query_params.get("thread_id") # Used by Message list + # For nested routes under /threads/{thread_id}/events/ or /threads/{thread_id}/accesses/ + # URL path kwargs take priority — ignore query params when URL provides thread_id + thread_id_from_url = view.kwargs.get("thread_id") + + # Query params are only used for flat routes (Thread list, Message list) + mailbox_id = ( + request.query_params.get("mailbox_id") if not thread_id_from_url else None + ) + thread_id = ( + request.query_params.get("thread_id") if not thread_id_from_url else None + ) # If it's a detail action (retrieve, update, destroy), object-level permission is checked # by has_object_permission. If it's a list action without filters, deny access. is_list_action = hasattr(view, "action") and view.action == "list" if not is_list_action: + # For create action on nested routes, check thread access with edit role + if ( + thread_id_from_url + and hasattr(view, "action") + and view.action == "create" + ): + return models.ThreadAccess.objects.filter( + thread_id=thread_id_from_url, + role__in=enums.THREAD_ROLES_CAN_EDIT, + mailbox__accesses__user=request.user, + ).exists() # Allow non-list actions (like detail views or specific APIViews like SendMessageView) # to proceed to object-level checks or handle permissions within the view. return True # --- The following logic only applies if is_list_action is True --- # + # Check access for nested thread routes (e.g., /threads/{id}/events/) + if thread_id_from_url: + return models.ThreadAccess.objects.filter( + thread_id=thread_id_from_url, + mailbox__accesses__user=request.user, + ).exists() + # Check access based on query params for LIST action if thread_id: # Check if the user has access to this specific thread to list messages @@ -143,6 +169,18 @@ def has_object_permission(self, request, view, obj): # Check access directly on the mailbox return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists() + if isinstance(obj, models.ThreadEvent): + thread = obj.thread + has_access = models.ThreadAccess.objects.filter( + thread=thread, mailbox__accesses__user=user + ).exists() + if not has_access: + return False + # Only the author can update or delete their own events + if view.action in ["update", "partial_update", "destroy"]: + return obj.author_id == user.id + return True + if isinstance(obj, (models.Message, models.Thread)): thread = obj.thread if isinstance(obj, models.Message) else obj # Check access via the message's thread using ThreadAccess diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 6e43d3419..f69415cdb 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -40,6 +40,20 @@ class ObjectJSONField(serializers.JSONField): """JSONField annotated as ``type: object`` for OpenAPI schema generation.""" +def _build_thread_event_data_schema(): + """Build the OpenAPI schema for ThreadEvent.data from model DATA_SCHEMAS. + + Returns a `oneOf` composition when multiple types are defined. + """ + schemas = models.ThreadEvent.DATA_SCHEMAS + return {"oneOf": list(schemas.values())} + + +@extend_schema_field(_build_thread_event_data_schema()) +class ThreadEventDataField(serializers.JSONField): + """JSONField for ThreadEvent.data, OpenAPI-annotated from model DATA_SCHEMAS.""" + + class IntegerChoicesField(serializers.ChoiceField): """ Custom field to handle IntegerChoices that accepts string labels for input @@ -948,6 +962,36 @@ class Meta: create_only_fields = ["thread", "mailbox"] +class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): + """Serialize thread event information.""" + + author = UserWithoutAbilitiesSerializer(read_only=True) + data = ThreadEventDataField() + + class Meta: + model = models.ThreadEvent + fields = [ + "id", + "thread", + "type", + "channel", + "message", + "author", + "data", + "created_at", + "updated_at", + ] + read_only_fields = [ + "id", + "thread", + "channel", + "author", + "created_at", + "updated_at", + ] + create_only_fields = ["type", "message"] + + class MailboxAccessReadSerializer(serializers.ModelSerializer): """Serialize mailbox access information for read operations with nested user details. Mailbox context is implied by the URL, so mailbox details are not included here. diff --git a/src/backend/core/api/viewsets/thread_event.py b/src/backend/core/api/viewsets/thread_event.py new file mode 100644 index 000000000..75eae9db2 --- /dev/null +++ b/src/backend/core/api/viewsets/thread_event.py @@ -0,0 +1,48 @@ +"""API ViewSet for ThreadEvent model.""" + +from django.shortcuts import get_object_or_404 + +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, viewsets + +from core import models + +from .. import permissions, serializers + + +@extend_schema(tags=["thread-events"]) +class ThreadEventViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + """ViewSet for ThreadEvent model.""" + + serializer_class = serializers.ThreadEventSerializer + pagination_class = None + permission_classes = [ + permissions.IsAuthenticated, + permissions.IsAllowedToAccess, + ] + lookup_field = "id" + lookup_url_kwarg = "id" + + def get_queryset(self): + """Restrict results to events for the specified thread.""" + thread_id = self.kwargs.get("thread_id") + if not thread_id: + return models.ThreadEvent.objects.none() + + return ( + models.ThreadEvent.objects.filter(thread_id=thread_id) + .select_related("author", "channel", "message") + .order_by("created_at") + ) + + def perform_create(self, serializer): + """Set thread from URL and author from request user.""" + thread = get_object_or_404(models.Thread, id=self.kwargs["thread_id"]) + serializer.save(thread=thread, author=self.request.user) diff --git a/src/backend/core/api/viewsets/thread_user.py b/src/backend/core/api/viewsets/thread_user.py new file mode 100644 index 000000000..05110fdd8 --- /dev/null +++ b/src/backend/core/api/viewsets/thread_user.py @@ -0,0 +1,37 @@ +"""API ViewSet to list users who have access to a thread.""" + +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, viewsets + +from core import models + +from .. import permissions, serializers + + +@extend_schema(tags=["thread-users"]) +class ThreadUserViewSet( + viewsets.GenericViewSet, + mixins.ListModelMixin, +): + """List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess).""" + + serializer_class = serializers.UserWithoutAbilitiesSerializer + pagination_class = None + permission_classes = [ + permissions.IsAuthenticated, + permissions.IsAllowedToManageThreadAccess, + ] + + def get_queryset(self): + """Return distinct users who have access to the thread.""" + thread_id = self.kwargs.get("thread_id") + if not thread_id: + return models.User.objects.none() + + return ( + models.User.objects.filter( + mailbox_accesses__mailbox__thread_accesses__thread_id=thread_id, + ) + .distinct() + .order_by("full_name", "email") + ) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index f8b3679ed..009308a9b 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -135,6 +135,12 @@ class MailboxAbilities(models.TextChoices): CAN_IMPORT_MESSAGES = "import_messages", "Can import messages" +class ThreadEventTypeChoices(models.TextChoices): + """Defines the possible types of thread events.""" + + IM = "im", "Instant message" + + class MessageTemplateTypeChoices(models.IntegerChoices): """Defines the possible types of message templates.""" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 3b3d5cc5c..4deed39d3 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -144,6 +144,18 @@ class Meta: ) +class ThreadEventFactory(factory.django.DjangoModelFactory): + """A factory to create thread events for testing purposes.""" + + class Meta: + model = models.ThreadEvent + + thread = factory.SubFactory(ThreadFactory) + type = "im" + data = factory.LazyAttribute(lambda o: {"content": fake.sentence()}) + author = factory.SubFactory(UserFactory) + + class ContactFactory(factory.django.DjangoModelFactory): """A factory to random contacts for testing purposes.""" diff --git a/src/backend/core/management/commands/createsuperuser.py b/src/backend/core/management/commands/createsuperuser.py index e45649e01..6e26dfa01 100644 --- a/src/backend/core/management/commands/createsuperuser.py +++ b/src/backend/core/management/commands/createsuperuser.py @@ -31,8 +31,13 @@ def handle(self, *args, **options): try: user = UserModel.objects.get(admin_email=email) except UserModel.DoesNotExist: - user = UserModel(admin_email=email) - message = "Superuser created successfully." + try: + user = UserModel.objects.get(email=email) + except UserModel.DoesNotExist: + user = UserModel(admin_email=email) + message = "Superuser created successfully." + else: + message = "User already existed and was upgraded to superuser." else: if user.is_superuser and user.is_staff: message = "Superuser already exists." diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index ea3bbbafb..a658127e2 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -381,7 +381,6 @@ def prepare_outbound_message( message.thread.update_stats() - # Clean up the draft blob and the attachment blobs if draft_blob: draft_blob.delete() diff --git a/src/backend/core/migrations/0023_threadevent.py b/src/backend/core/migrations/0023_threadevent.py new file mode 100644 index 000000000..c186ae9de --- /dev/null +++ b/src/backend/core/migrations/0023_threadevent.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.11 on 2026-03-19 18:52 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0022_backfill_thread_messaged_at'), + ] + + operations = [ + migrations.CreateModel( + name='ThreadEvent', + 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')), + ('type', models.CharField(choices=[('im', 'Instant message')], max_length=36, verbose_name='type')), + ('data', models.JSONField(blank=True, default=dict, verbose_name='data')), + ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thread_events', to=settings.AUTH_USER_MODEL)), + ('channel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thread_events', to='core.channel')), + ('message', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='thread_events', to='core.message')), + ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='core.thread')), + ], + options={ + 'verbose_name': 'thread event', + 'verbose_name_plural': 'thread events', + 'db_table': 'messages_threadevent', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['thread', 'created_at'], name='messages_th_thread__83c471_idx')], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index bac111546..42e06f396 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -44,6 +44,7 @@ MessageRecipientTypeChoices, MessageTemplateTypeChoices, ThreadAccessRoleChoices, + ThreadEventTypeChoices, UserAbilities, ) from core.mda.rfc5322 import EmailParseError, parse_email_message @@ -1350,6 +1351,88 @@ def starred_filter(): return Q(starred_at__isnull=False) +class ThreadEvent(BaseModel): + """Thread event model to store events in a thread timeline (internal comments, notifications, etc.).""" + + DATA_SCHEMAS = { + ThreadEventTypeChoices.IM: { + "type": "object", + "properties": { + "content": { + "type": "string", + }, + "mentions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + }, + "required": ["id", "name"], + "additionalProperties": False, + }, + }, + }, + "required": ["content"], + "additionalProperties": False, + }, + } + + thread = models.ForeignKey( + "Thread", on_delete=models.CASCADE, related_name="events" + ) + type = models.CharField( + "type", + max_length=36, + choices=ThreadEventTypeChoices.choices, + ) + channel = models.ForeignKey( + "Channel", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="thread_events", + ) + message = models.ForeignKey( + "Message", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="thread_events", + ) + author = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="thread_events", + ) + data = models.JSONField("data", default=dict, blank=True) + + class Meta: + db_table = "messages_threadevent" + verbose_name = "thread event" + verbose_name_plural = "thread events" + ordering = ["created_at"] + indexes = [ + models.Index(fields=["thread", "created_at"]), + ] + + def __str__(self): + return f"{self.thread} - {self.type} - {self.created_at}" + + def clean(self): + """Validate the data field against the schema for this event type.""" + schema = self.DATA_SCHEMAS.get(self.type) + if schema: + try: + jsonschema.validate(self.data, schema) + except jsonschema.ValidationError as exception: + raise ValidationError({"data": exception.message}) from exception + super().clean() + + class Contact(BaseModel): """Contact model to store contact information.""" diff --git a/src/backend/core/tests/api/test_thread_event.py b/src/backend/core/tests/api/test_thread_event.py new file mode 100644 index 000000000..970eb01d6 --- /dev/null +++ b/src/backend/core/tests/api/test_thread_event.py @@ -0,0 +1,328 @@ +"""Tests for the ThreadEvent API endpoints.""" + +import uuid + +from django.urls import reverse + +import pytest +from rest_framework import status + +from core import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def get_thread_event_url(thread_id, event_id=None): + """Helper function to get the thread event URL.""" + if event_id: + return reverse( + "thread-event-detail", kwargs={"thread_id": thread_id, "id": event_id} + ) + return reverse("thread-event-list", kwargs={"thread_id": thread_id}) + + +def setup_user_with_thread_access(role=enums.ThreadAccessRoleChoices.EDITOR): + """Create a user with mailbox access and thread access.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=role, + ) + return user, mailbox, thread + + +class TestThreadEventList: + """Test the GET /threads/{thread_id}/events/ endpoint.""" + + def test_list_thread_events_success(self, api_client): + """Test listing thread events of a thread.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create some events for this thread + factories.ThreadEventFactory.create_batch(3, thread=thread, author=user) + # Create events for another thread (should not appear) + factories.ThreadEventFactory.create_batch(2) + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 3 + + def test_list_thread_events_viewer_access(self, api_client): + """Test listing thread events with viewer access succeeds.""" + user, _mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.VIEWER + ) + api_client.force_authenticate(user=user) + + factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + def test_list_thread_events_forbidden(self, api_client): + """Test listing thread events without thread access.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_thread_events_unauthorized(self, api_client): + """Test listing thread events without authentication.""" + thread = factories.ThreadFactory() + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventCreate: + """Test the POST /threads/{thread_id}/events/ endpoint.""" + + def test_create_thread_event_im_success(self, api_client): + """Test creating an IM thread event successfully.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "im", + "data": {"content": "This is an internal comment."}, + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["type"] == "im" + assert response.data["data"]["content"] == "This is an internal comment." + assert response.data["author"]["id"] == str(user.id) + assert response.data["thread"] == thread.id + + def test_create_thread_event_with_invalid_type(self, api_client): + """ + Test creating a thread event with invalid type. + Should be forbidden, if type is not a valid choice. + """ + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "notification", + "data": {"content": "Status changed", "status": "resolved"}, + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["type"][0].code == "invalid_choice" + assert str(response.data["type"][0]) == '"notification" is not a valid choice.' + + def test_create_thread_event_forbidden(self, api_client): + """Test creating a thread event without thread access.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + thread = factories.ThreadFactory() + data = {"type": "im", "data": {"content": "test"}} + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_thread_event_unauthorized(self, api_client): + """Test creating a thread event without authentication.""" + thread = factories.ThreadFactory() + response = api_client.post(get_thread_event_url(thread.id), {}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_create_thread_event_thread_from_url(self, api_client): + """Test that thread is always set from URL, not request body.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_thread = factories.ThreadFactory() + data = { + "type": "im", + "data": {"content": "test"}, + "thread": str(other_thread.id), + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + # Thread should be from URL, not body + assert response.data["thread"] == thread.id + + +class TestThreadEventRetrieve: + """Test the GET /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_retrieve_thread_event_success(self, api_client): + """Test retrieving a thread event.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_200_OK + assert response.data["id"] == str(event.id) + assert response.data["type"] == event.type + assert response.data["data"] == event.data + + def test_retrieve_thread_event_forbidden(self, api_client): + """Test retrieving a thread event without access.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory() + response = api_client.get(get_thread_event_url(event.thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_retrieve_thread_event_not_found(self, api_client): + """Test retrieving a non-existent thread event.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_event_url(thread.id, uuid.uuid4())) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestThreadEventUpdate: + """Test the PATCH /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_update_thread_event_data(self, api_client): + """Test updating thread event data.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "Updated comment"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["data"] == {"content": "Updated comment"} + + def test_update_thread_event_type_readonly_on_update(self, api_client): + """Test that type is read-only on update (create-only field).""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user, type="im") + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"type": "notification"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + # Type should not change (create-only) + event.refresh_from_db() + assert event.type == "im" + + +class TestThreadEventDelete: + """Test the DELETE /threads/{thread_id}/events/{id}/ endpoint.""" + + def test_delete_thread_event_success(self, api_client): + """Test deleting a thread event.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not models.ThreadEvent.objects.filter(id=event.id).exists() + + def test_delete_thread_event_forbidden(self, api_client): + """Test deleting a thread event without access.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory() + response = api_client.delete(get_thread_event_url(event.thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_delete_thread_event_unauthorized(self, api_client): + """Test deleting a thread event without authentication.""" + event = factories.ThreadEventFactory() + response = api_client.delete(get_thread_event_url(event.thread.id, event.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestThreadEventDataValidation: + """Test that the data field is validated against the JSON schema for each event type.""" + + def test_create_im_event_missing_content(self, api_client): + """IM events require a 'content' key in data.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = {"type": "im", "data": {}} + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "data" in response.data + + def test_create_im_event_with_valid_mentions(self, api_client): + """IM events should accept a valid mentions array.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + data = { + "type": "im", + "data": { + "content": "Hey @[John]", + "mentions": [{"id": str(other_user.id), "name": "John"}], + }, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["data"]["mentions"][0]["id"] == str(other_user.id) + + def test_create_im_event_with_invalid_mention_shape(self, api_client): + """IM events must reject mentions with missing required fields.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "im", + "data": { + "content": "Hey @[John]", + "mentions": [{"name": "John"}], # missing 'id' + }, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "data" in response.data + + def test_create_im_event_rejects_extra_fields(self, api_client): + """IM events must reject unexpected fields in data (additionalProperties: false).""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "im", + "data": {"content": "test", "malicious_field": "injected"}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "data" in response.data + + def test_create_im_event_content_not_string(self, api_client): + """IM events must reject non-string content.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = {"type": "im", "data": {"content": 12345}} + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "data" in response.data diff --git a/src/backend/core/tests/api/test_thread_event_permissions.py b/src/backend/core/tests/api/test_thread_event_permissions.py new file mode 100644 index 000000000..34880c662 --- /dev/null +++ b/src/backend/core/tests/api/test_thread_event_permissions.py @@ -0,0 +1,380 @@ +"""Security tests for the ThreadEvent API — edge cases, IDOR, privilege escalation.""" + +from django.urls import reverse + +import pytest +from rest_framework import status + +from core import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def get_thread_event_url(thread_id, event_id=None): + """Helper function to get the thread event URL.""" + if event_id: + return reverse( + "thread-event-detail", kwargs={"thread_id": thread_id, "id": event_id} + ) + return reverse("thread-event-list", kwargs={"thread_id": thread_id}) + + +def setup_user_with_thread_access(role=enums.ThreadAccessRoleChoices.EDITOR): + """Create a user with mailbox access and thread access.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=role, + ) + return user, mailbox, thread + + +class TestCrossThreadEventAccess: + """Test that events from one thread cannot be accessed via another thread's URL.""" + + def test_retrieve_event_from_different_thread_returns_404(self, api_client): + """GET /threads/T1/events/E2_id/ where E2 belongs to T2 should return 404.""" + user, mailbox, thread_a = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create a second thread the user also has access to + thread_b = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread_b) + + # Create event in thread B + event_b = factories.ThreadEventFactory(thread=thread_b, author=user) + + # Try to access event_b via thread A's URL + response = api_client.get(get_thread_event_url(thread_a.id, event_b.id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_update_event_from_different_thread_returns_404(self, api_client): + """PATCH /threads/T1/events/E2_id/ where E2 belongs to T2 should return 404.""" + user, mailbox, thread_a = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + thread_b = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread_b) + event_b = factories.ThreadEventFactory(thread=thread_b, author=user) + + response = api_client.patch( + get_thread_event_url(thread_a.id, event_b.id), + {"data": {"content": "hijacked"}}, + format="json", + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify event was not modified + event_b.refresh_from_db() + assert event_b.data.get("content") != "hijacked" + + def test_delete_event_from_different_thread_returns_404(self, api_client): + """DELETE /threads/T1/events/E2_id/ where E2 belongs to T2 should return 404.""" + user, mailbox, thread_a = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + thread_b = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=thread_b) + event_b = factories.ThreadEventFactory(thread=thread_b, author=user) + + response = api_client.delete(get_thread_event_url(thread_a.id, event_b.id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify event still exists + assert models.ThreadEvent.objects.filter(id=event_b.id).exists() + + +class TestReadOnlyFieldManipulation: + """Test that read-only and create-only fields cannot be manipulated.""" + + def test_create_cannot_set_author(self, api_client): + """Author should always be set from request.user, not from body.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + impersonated_user = factories.UserFactory() + data = { + "type": "im", + "data": {"content": "test"}, + "author": str(impersonated_user.id), + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert response.data["author"]["id"] == str(user.id) + + def test_update_cannot_change_author(self, api_client): + """PATCH should not allow changing the author field.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"author": str(other_user.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.author_id == user.id + + def test_update_cannot_change_thread(self, api_client): + """PATCH should not allow moving an event to a different thread.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_thread = factories.ThreadFactory() + factories.ThreadAccessFactory(mailbox=mailbox, thread=other_thread) + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"thread": str(other_thread.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.thread_id == thread.id + + def test_update_cannot_change_channel(self, api_client): + """PATCH should not allow changing the channel field.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + channel = factories.ChannelFactory() + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"channel": str(channel.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.channel_id is None + + def test_update_cannot_change_type(self, api_client): + """PATCH should not allow changing the type (create-only).""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user, type="im") + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"type": "notification"}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.type == "im" + + def test_update_cannot_change_message(self, api_client): + """PATCH should not allow changing the message FK (create-only).""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + message = factories.MessageFactory(thread=thread) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"message": str(message.id)}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + event.refresh_from_db() + assert event.message_id is None + + def test_create_cannot_set_timestamps(self, api_client): + """created_at and updated_at should not be user-controlled.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "im", + "data": {"content": "test"}, + "created_at": "2000-01-01T00:00:00Z", + "updated_at": "2000-01-01T00:00:00Z", + } + + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert not response.data["created_at"].startswith("2000") + assert not response.data["updated_at"].startswith("2000") + + +class TestNonAuthorEventManipulation: + """Test that users other than the author cannot edit/delete events.""" + + def test_other_user_cannot_update_event(self, api_client): + """A user who is not the author should not be able to update an event.""" + author, mailbox, thread = setup_user_with_thread_access() + other_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=other_user, role=enums.MailboxRoleChoices.ADMIN + ) + + event = factories.ThreadEventFactory(thread=thread, author=author) + + api_client.force_authenticate(user=other_user) + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "hijacked by other user"}}, + format="json", + ) + # Should be forbidden — only author should update their own events + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_other_user_cannot_delete_event(self, api_client): + """A user who is not the author should not be able to delete an event.""" + author, mailbox, thread = setup_user_with_thread_access() + other_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=other_user, role=enums.MailboxRoleChoices.ADMIN + ) + + event = factories.ThreadEventFactory(thread=thread, author=author) + + api_client.force_authenticate(user=other_user) + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + # Should be forbidden — only author should delete their own events + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Verify event still exists + assert models.ThreadEvent.objects.filter(id=event.id).exists() + + def test_author_can_update_own_event(self, api_client): + """The author should be able to update their own event.""" + author, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory(thread=thread, author=author) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "updated by author"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + assert response.data["data"]["content"] == "updated by author" + + def test_author_can_delete_own_event(self, api_client): + """The author should be able to delete their own event.""" + author, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory(thread=thread, author=author) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + +class TestViewerCannotCreateEvents: + """Test that VIEWER-role users cannot create events (only EDITOR+).""" + + def test_viewer_cannot_create_event(self, api_client): + """Users with only VIEWER role on a thread should not create events.""" + user, _mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.VIEWER + ) + api_client.force_authenticate(user=user) + + data = {"type": "im", "data": {"content": "from a viewer"}} + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_editor_can_create_event(self, api_client): + """Users with EDITOR role should be able to create events (sanity check).""" + user, _mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.EDITOR + ) + api_client.force_authenticate(user=user) + + data = {"type": "im", "data": {"content": "from an editor"}} + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + +class TestParameterConfusionAttack: + """Test that conflicting thread_id in URL path vs query params can't bypass permissions.""" + + def test_url_thread_id_ignores_query_param_thread_id(self, api_client): + """Passing ?thread_id=X on a nested /threads/Y/events/ should not affect results.""" + user, _mailbox, thread_a = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread B the user has no access to + thread_b = factories.ThreadFactory() + factories.ThreadEventFactory(thread=thread_b) + + # Try to list events with confusing params + url = f"{get_thread_event_url(thread_a.id)}?thread_id={thread_b.id}" + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + # Should only return events from thread_a (the URL path), not thread_b + for event in response.data: + assert event["thread"] == thread_a.id + + def test_url_thread_id_ignores_query_param_mailbox_id(self, api_client): + """Passing ?mailbox_id=X on a nested /threads/Y/events/ should not affect permissions.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Another mailbox the user has no access to + other_mailbox = factories.MailboxFactory() + + url = f"{get_thread_event_url(thread.id)}?mailbox_id={other_mailbox.id}" + response = api_client.get(url) + # Permission should be based on URL thread_id, not the query param + assert response.status_code == status.HTTP_200_OK + + +class TestAccessRevocation: + """Test that access revocation properly blocks subsequent operations.""" + + def test_revoked_mailbox_access_blocks_event_listing(self, api_client): + """After mailbox access is revoked, user cannot list events.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + factories.ThreadEventFactory(thread=thread, author=user) + + # Verify access works + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + # Revoke access + models.MailboxAccess.objects.filter(mailbox=mailbox, user=user).delete() + + # Verify access is blocked + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_revoked_access_blocks_event_retrieval(self, api_client): + """After access revocation, user cannot retrieve specific events.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + + # Verify access works + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_200_OK + + # Revoke access + models.MailboxAccess.objects.filter(mailbox=mailbox, user=user).delete() + + # Verify access is blocked + response = api_client.get(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/src/backend/core/tests/api/test_thread_user.py b/src/backend/core/tests/api/test_thread_user.py new file mode 100644 index 000000000..ed432e3c9 --- /dev/null +++ b/src/backend/core/tests/api/test_thread_user.py @@ -0,0 +1,432 @@ +"""Tests for the ThreadUser API endpoints.""" + +import uuid + +from django.urls import reverse + +import pytest +from rest_framework import status + +from core import enums, factories + +pytestmark = pytest.mark.django_db + + +def get_thread_user_url(thread_id): + """Helper function to get the thread user list URL.""" + return reverse("thread-user-list", kwargs={"thread_id": thread_id}) + + +def setup_user_with_thread_access( + thread_role=enums.ThreadAccessRoleChoices.EDITOR, + mailbox_role=enums.MailboxRoleChoices.ADMIN, +): + """Create a user with mailbox access and thread access. + + Returns (user, mailbox, thread). + """ + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=mailbox_role, + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=thread_role, + ) + return user, mailbox, thread + + +class TestThreadUserListAuthentication: + """Test authentication requirements for GET /threads/{thread_id}/users/.""" + + def test_list_thread_users_unauthorized(self, api_client): + """An unauthenticated request must return 401.""" + thread = factories.ThreadFactory() + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_thread_users_nonexistent_thread(self, api_client): + """Requesting users of a non-existent thread must return 403.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(uuid.uuid4())) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestThreadUserListPermissions: + """Test permission matrix for GET /threads/{thread_id}/users/. + + The endpoint uses IsAllowedToManageThreadAccess which requires: + - ThreadAccess role = EDITOR on the thread + - MailboxAccess role in MAILBOX_ROLES_CAN_EDIT (EDITOR, SENDER, ADMIN) + """ + + @pytest.mark.parametrize( + "thread_access_role, mailbox_access_role", + [ + (enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.ADMIN), + (enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.EDITOR), + (enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.SENDER), + ], + ) + def test_list_thread_users_allowed( + self, api_client, thread_access_role, mailbox_access_role + ): + """Users with EDITOR thread access + EDITOR/SENDER/ADMIN mailbox role can list.""" + user, _mailbox, thread = setup_user_with_thread_access( + thread_role=thread_access_role, + mailbox_role=mailbox_access_role, + ) + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + @pytest.mark.parametrize( + "thread_access_role, mailbox_access_role", + [ + # VIEWER on thread — regardless of mailbox role + (enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.ADMIN), + (enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.EDITOR), + (enums.ThreadAccessRoleChoices.VIEWER, enums.MailboxRoleChoices.SENDER), + # EDITOR on thread but only VIEWER on mailbox + (enums.ThreadAccessRoleChoices.EDITOR, enums.MailboxRoleChoices.VIEWER), + ], + ) + def test_list_thread_users_forbidden( + self, api_client, thread_access_role, mailbox_access_role + ): + """Users without sufficient roles are denied.""" + user, _mailbox, thread = setup_user_with_thread_access( + thread_role=thread_access_role, + mailbox_role=mailbox_access_role, + ) + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_list_thread_users_no_thread_access(self, api_client): + """A user with a mailbox but no ThreadAccess at all is denied.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + + # Thread exists but user's mailbox has no ThreadAccess + thread = factories.ThreadFactory() + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestThreadUserListResponse: + """Test the response format and content of GET /threads/{thread_id}/users/.""" + + def test_response_fields(self, api_client): + """Each user object must contain id, email, full_name, custom_attributes.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) >= 1 + + user_data = response.data[0] + assert "id" in user_data + assert "email" in user_data + assert "full_name" in user_data + assert "custom_attributes" in user_data + # Must not contain abilities + assert "abilities" not in user_data + + def test_returns_users_from_single_mailbox(self, api_client): + """All MailboxAccess users of a thread's mailbox are returned.""" + user, mailbox, thread = setup_user_with_thread_access() + + # Add two more users on the same mailbox + extra_user_1 = factories.UserFactory() + extra_user_2 = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=extra_user_1, role=enums.MailboxRoleChoices.VIEWER + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=extra_user_2, role=enums.MailboxRoleChoices.EDITOR + ) + + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + returned_ids = {u["id"] for u in response.data} + assert str(user.id) in returned_ids + assert str(extra_user_1.id) in returned_ids + assert str(extra_user_2.id) in returned_ids + + def test_returns_users_from_multiple_mailboxes(self, api_client): + """Users from all mailboxes that have ThreadAccess are returned.""" + user, _mailbox, thread = setup_user_with_thread_access() + + # Second mailbox with its own users, also having access to the thread + mailbox_b = factories.MailboxFactory() + user_b1 = factories.UserFactory() + user_b2 = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=user_b1, role=enums.MailboxRoleChoices.ADMIN + ) + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=user_b2, role=enums.MailboxRoleChoices.VIEWER + ) + factories.ThreadAccessFactory( + mailbox=mailbox_b, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + returned_ids = {u["id"] for u in response.data} + # Users from mailbox_a + assert str(user.id) in returned_ids + # Users from mailbox_b + assert str(user_b1.id) in returned_ids + assert str(user_b2.id) in returned_ids + + def test_users_are_deduplicated(self, api_client): + """A user with MailboxAccess on multiple mailboxes sharing the same thread + must appear only once in the result.""" + user, _mailbox, thread = setup_user_with_thread_access() + + # Same user also has access via a second mailbox + mailbox_b = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=user, role=enums.MailboxRoleChoices.VIEWER + ) + factories.ThreadAccessFactory( + mailbox=mailbox_b, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + returned_ids = [u["id"] for u in response.data] + assert returned_ids.count(str(user.id)) == 1 + + def test_users_scoped_to_thread(self, api_client): + """Users from other threads must not appear.""" + user, _mailbox, thread = setup_user_with_thread_access() + + # Another thread with a different mailbox and users + other_mailbox = factories.MailboxFactory() + other_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=other_mailbox, user=other_user, role=enums.MailboxRoleChoices.ADMIN + ) + other_thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=other_mailbox, + thread=other_thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + returned_ids = {u["id"] for u in response.data} + assert str(other_user.id) not in returned_ids + + def test_ordering(self, api_client): + """Users are returned ordered by full_name then email.""" + user, mailbox, thread = setup_user_with_thread_access() + + alice = factories.UserFactory(full_name="Alice Durand", email="alice@test.com") + bob = factories.UserFactory(full_name="Bob Martin", email="bob@test.com") + factories.MailboxAccessFactory( + mailbox=mailbox, user=alice, role=enums.MailboxRoleChoices.VIEWER + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=bob, role=enums.MailboxRoleChoices.VIEWER + ) + + api_client.force_authenticate(user=user) + + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + names = [u["full_name"] for u in response.data] + assert names == sorted(names) + + +class TestThreadUserListQueryOptimization: + """Test that the endpoint uses a reasonable number of queries.""" + + def test_constant_queries_regardless_of_user_count( + self, api_client, django_assert_num_queries + ): + """Query count should not grow with the number of users.""" + user, mailbox, thread = setup_user_with_thread_access() + + # Add many users on the mailbox + for _ in range(15): + extra = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=extra, role=enums.MailboxRoleChoices.VIEWER + ) + + api_client.force_authenticate(user=user) + + # 1 query for permission check, 1 for user list + with django_assert_num_queries(2): + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 16 # 15 extras + the admin user + + +class TestThreadUserListReadOnly: + """Verify the endpoint only exposes the list action.""" + + def test_post_not_allowed(self, api_client): + """POST must be rejected.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.post( + get_thread_user_url(thread.id), + {"email": "hacker@example.com"}, + format="json", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_put_not_allowed(self, api_client): + """PUT must be rejected.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.put( + get_thread_user_url(thread.id), + {"email": "hacker@example.com"}, + format="json", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_patch_not_allowed(self, api_client): + """PATCH must be rejected.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.patch( + get_thread_user_url(thread.id), + {"email": "hacker@example.com"}, + format="json", + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + def test_delete_not_allowed(self, api_client): + """DELETE must be rejected.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.delete(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +class TestThreadUserListIsolation: + """Test cross-thread and cross-mailbox isolation.""" + + def test_cannot_see_users_of_foreign_thread(self, api_client): + """A user with editor access on thread A must not be able to list + users of thread B where they have no access.""" + user, _mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread B — user has no access + thread_b = factories.ThreadFactory() + other_mailbox = factories.MailboxFactory() + factories.ThreadAccessFactory( + mailbox=other_mailbox, + thread=thread_b, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get(get_thread_user_url(thread_b.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_orphan_mailbox_users_not_returned(self, api_client): + """Users from a mailbox with no MailboxAccess linked to the thread + must not appear in the results.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + thread = factories.ThreadFactory() + # Thread access exists for an orphan mailbox (no MailboxAccess) + orphan_mailbox = factories.MailboxFactory() + factories.ThreadAccessFactory( + mailbox=orphan_mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + # But user's mailbox has editor access (so permission check passes) + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=user) + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + # Only the requesting user's mailbox has MailboxAccess → only that user + returned_ids = {u["id"] for u in response.data} + assert returned_ids == {str(user.id)} + + def test_mailbox_access_role_does_not_filter_users(self, api_client): + """All users with any MailboxAccess role (VIEWER, EDITOR, SENDER, ADMIN) + on a thread-linked mailbox should be returned, regardless of their role.""" + user, mailbox, thread = setup_user_with_thread_access() + + viewer = factories.UserFactory() + editor = factories.UserFactory() + sender = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=viewer, role=enums.MailboxRoleChoices.VIEWER + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=editor, role=enums.MailboxRoleChoices.EDITOR + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=sender, role=enums.MailboxRoleChoices.SENDER + ) + + api_client.force_authenticate(user=user) + response = api_client.get(get_thread_user_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + returned_ids = {u["id"] for u in response.data} + assert str(viewer.id) in returned_ids + assert str(editor.id) in returned_ids + assert str(sender.id) in returned_ids diff --git a/src/backend/core/tests/mda/test_autoreply.py b/src/backend/core/tests/mda/test_autoreply.py index 813c07d9c..9bfaaa7fd 100644 --- a/src/backend/core/tests/mda/test_autoreply.py +++ b/src/backend/core/tests/mda/test_autoreply.py @@ -918,7 +918,6 @@ def test_has_attachments_persisted_with_inline_signature_images( autoreply_msg.refresh_from_db() assert autoreply_msg.has_attachments is True - def test_does_not_update_sender_read_at( self, mailbox, autoreply_template, inbound_message ): diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index cb482f4d4..f345b78cc 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -42,6 +42,8 @@ from core.api.viewsets.task import TaskDetailView from core.api.viewsets.thread import ThreadViewSet from core.api.viewsets.thread_access import ThreadAccessViewSet +from core.api.viewsets.thread_event import ThreadEventViewSet +from core.api.viewsets.thread_user import ThreadUserViewSet from core.api.viewsets.user import UserViewSet from core.authentication.urls import urlpatterns as oidc_urls @@ -66,6 +68,12 @@ thread_access_nested_router.register( r"accesses", ThreadAccessViewSet, basename="thread-access" ) +thread_access_nested_router.register( + r"events", ThreadEventViewSet, basename="thread-event" +) +thread_access_nested_router.register( + r"users", ThreadUserViewSet, basename="thread-user" +) # Router for /mailboxes/{mailbox_id}/accesses/ mailbox_access_nested_router = DefaultRouter() diff --git a/src/backend/e2e/management/commands/e2e_demo.py b/src/backend/e2e/management/commands/e2e_demo.py index c50297699..062cf3749 100644 --- a/src/backend/e2e/management/commands/e2e_demo.py +++ b/src/backend/e2e/management/commands/e2e_demo.py @@ -131,15 +131,19 @@ def handle(self, *args, **options): ) # Step 6: Create outbox test data for each browser - self.stdout.write("\n-- 5/6 📬 Creating outbox test data") + self.stdout.write("\n-- 5/7 📬 Creating outbox test data") for browser in BROWSERS: self._create_outbox_test_data(domain, browser) # Step 7: Create inbox test data for each browser - self.stdout.write("\n-- 6/6 📥 Creating inbox test data") + self.stdout.write("\n-- 6/7 📥 Creating inbox test data") for browser in BROWSERS: self._create_inbox_test_data(domain, browser) + # Step 8: Create shared mailbox thread data for IM testing + self.stdout.write("\n-- 7/7 💬 Creating shared mailbox thread for IM testing") + self._create_shared_mailbox_thread_data(shared_mailbox) + def _create_user_with_mailbox( self, email, domain, is_domain_admin=False, is_superuser=False ): @@ -464,3 +468,49 @@ def _create_thread_with_message(self, mailbox, sender_contact, subject, recipien thread.update_stats() return thread + + def _create_shared_mailbox_thread_data(self, shared_mailbox): + """Create a thread in the shared mailbox for testing internal messages (IM).""" + subject = "Shared inbox thread for IM" + + # Clean up existing thread + existing = models.Thread.objects.filter( + subject=subject, + accesses__mailbox=shared_mailbox, + ) + deleted_count = existing.count() + if deleted_count > 0: + existing.delete() + self.stdout.write( + self.style.WARNING( + f" ⚠ Deleted {deleted_count} existing shared mailbox IM thread(s)" + ) + ) + + # Create thread with EDITOR access so the IM input is visible + thread = models.Thread.objects.create(subject=subject) + models.ThreadAccess.objects.create( + thread=thread, + mailbox=shared_mailbox, + role=ThreadAccessRoleChoices.EDITOR, + ) + + # Create an external sender message so the thread appears in inbox + sender_contact, _ = models.Contact.objects.get_or_create( + email="external@external.invalid", + mailbox=shared_mailbox, + defaults={"name": "External Sender"}, + ) + models.Message.objects.create( + thread=thread, + sender=sender_contact, + subject=subject, + is_sender=False, + is_draft=False, + ) + + thread.update_stats() + + self.stdout.write( + self.style.SUCCESS(f" ✓ Shared mailbox IM thread created: {subject}") + ) diff --git a/src/e2e/src/__tests__/message-import.spec.ts b/src/e2e/src/__tests__/message-import.spec.ts index 37b00493f..dce2d862b 100644 --- a/src/e2e/src/__tests__/message-import.spec.ts +++ b/src/e2e/src/__tests__/message-import.spec.ts @@ -109,21 +109,15 @@ test.describe("Import Message", () => { const email = `user.e2e.${browserName}@example.local`; await page.waitForLoadState("networkidle"); - // Go to the shared mailbox and check if the user has sender rights + // Go to the shared mailbox where the user only has sender rights await page.getByRole("button", { name: email }).click(); await page .getByRole("menuitem", { name: getMailboxEmail("shared") }) .click(); await page.waitForLoadState("networkidle"); - // As the database is fresh, there should be no threads but the Import messages button should not be visible + // The header settings menu should not contain the Import messages option // as the user does not have admin rights to the shared mailbox - await expect(page.getByText("No threads")).toBeVisible(); - await expect( - page.getByRole("link", { name: "Import messages" }) - ).not.toBeVisible(); - - // And also in the header settings menu, there should be no entry to import messages const header = page.locator(".c__header"); const settingsButton = header.getByRole("button", { name: "More options" }); await settingsButton.click(); diff --git a/src/e2e/src/__tests__/thread-event.spec.ts b/src/e2e/src/__tests__/thread-event.spec.ts new file mode 100644 index 000000000..3b6484836 --- /dev/null +++ b/src/e2e/src/__tests__/thread-event.spec.ts @@ -0,0 +1,392 @@ +import test, { expect, Page } from "@playwright/test"; +import { resetDatabase, getMailboxEmail } from "../utils"; +import { signInKeycloakIfNeeded } from "../utils-test"; +import { BrowserName } from "../types"; + +/** + * Navigate to the shared mailbox and open the IM test thread. + */ +async function navigateToSharedThread(page: Page, browserName: BrowserName) { + // Wait for page to be fully loaded before interacting with the dropdown + await page.waitForLoadState("networkidle"); + + // Switch from personal mailbox to shared mailbox + await page + .getByRole("button", { name: getMailboxEmail("user", browserName) }) + .click(); + await page + .getByRole("menuitem", { name: getMailboxEmail("shared") }) + .click(); + await page.waitForLoadState("networkidle"); + + // Navigate to inbox and open the IM thread + await page.getByRole("link", { name: /^inbox/i }).click(); + await page.waitForLoadState("networkidle"); + await page + .getByRole("link", { name: "Shared inbox thread for IM" }) + .first() + .click(); + await page + .getByRole("heading", { + name: "Shared inbox thread for IM", + level: 2, + }) + .waitFor({ state: "visible" }); + await page.waitForLoadState("networkidle"); +} + +test.describe("Thread Events (Internal Messages)", () => { + test.beforeAll(async () => { + await resetDatabase(); + }); + + test.beforeEach(async ({ page, browserName }) => { + await signInKeycloakIfNeeded({ + page, + username: `user.e2e.${browserName}`, + }); + }); + + test("should show IM input on shared mailbox thread", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + await expect( + page.getByPlaceholder("Add internal comment..."), + ).toBeVisible(); + + // Send button should be visible but disabled when input is empty + const sendButton = page + .locator(".thread-event-input") + .getByRole("button", { name: "Send" }); + await expect(sendButton).toBeVisible(); + await expect(sendButton).toBeDisabled(); + }); + + test("should not show IM input on personal mailbox inbox thread", async ({ + page, + }) => { + await page.waitForLoadState("networkidle"); + + await page.getByRole("link", { name: /^inbox/i }).click(); + await page.waitForLoadState("networkidle"); + + await page + .getByRole("link", { name: "Inbox thread alpha" }) + .first() + .click(); + await page + .getByRole("heading", { name: "Inbox thread alpha", level: 2 }) + .waitFor({ state: "visible" }); + await page.waitForLoadState("networkidle"); + + await expect(page.locator(".thread-event-input")).not.toBeVisible(); + }); + + test("should send an internal message and display it as a chat bubble", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Hello from E2E test"); + + await page + .locator(".thread-event-input") + .getByRole("button", { name: "Send" }) + .click(); + await page.waitForLoadState("networkidle"); + + // Verify the IM bubble appears with the correct content + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Hello from E2E test" }); + await expect(bubble).toBeVisible(); + + // Verify author bubble shows fullanme + await expect(bubble.locator(".thread-event__author")).toContainText( + `User E2E ${browserName}`, { ignoreCase: true } + ); + + // Verify input is cleared + await expect(imInput).toHaveValue(""); + }); + + test("should send an IM by pressing Enter", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Sent with Enter key"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Sent with Enter key" }); + await expect(bubble).toBeVisible(); + }); + + test("should condense consecutive messages from same author", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + const imInput = page.getByPlaceholder("Add internal comment..."); + + // Send two quick messages + await imInput.fill("First quick message"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + await imInput.fill("Second quick message"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + // Verify at least one condensed message exists (no repeated header) + await expect( + page.locator(".thread-event--condensed").first(), + ).toBeVisible(); + }); + + test("should suggest users when typing @ and insert mention on selection", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + const imInput = page.getByPlaceholder("Add internal comment..."); + + // Type @ followed by filter text to trigger the mention popover + await imInput.pressSequentially("@Mailbox"); + + // Wait for the suggestion popover to open + const popover = page.locator(".suggestion-input__popover--open"); + await expect(popover).toBeVisible(); + + // Verify the expected user appears in suggestions + const browserTitle = + browserName.charAt(0).toUpperCase() + browserName.slice(1); + const expectedName = `Mailbox_Admin E2E ${browserTitle}`; + const suggestionItem = page + .locator(".suggestion-input__item") + .filter({ hasText: expectedName }); + await expect(suggestionItem).toBeVisible(); + + // Click the suggestion to insert the mention + await suggestionItem.click(); + + // Verify the mention is inserted in the textarea + await expect(imInput).toHaveValue(new RegExp(`@${expectedName} `)); + + // Add text after the mention and send + await imInput.pressSequentially("can you check this?"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + // Verify the IM bubble contains a rendered mention + const mentionBubble = page + .locator(".thread-event--im") + .filter({ hasText: "can you check this?" }); + await expect(mentionBubble).toBeVisible(); + await expect( + mentionBubble.locator(".thread-event__mention"), + ).toContainText(`@${expectedName}`); + }); + + test("should edit an existing IM and show edited badge", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Send a fresh message to edit + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Message before edit"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + // Locate the bubble we just sent + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Message before edit" }); + await expect(bubble).toBeVisible(); + + // Hover the bubble to reveal action buttons + await bubble.locator(".thread-event__bubble").hover(); + + // Click the Edit button + await bubble.getByRole("button", { name: "Edit" }).click(); + + // Verify edit mode UI + await expect(page.locator(".thread-event-input__edit-banner")).toBeVisible(); + await expect( + page + .locator(".thread-event-input__edit-banner") + .getByText("Editing message"), + ).toBeVisible(); + + const saveButton = page + .locator(".thread-event-input") + .getByRole("button", { name: "Save" }); + await expect(saveButton).toBeVisible(); + + // Verify input is pre-populated with the original message + await expect(imInput).toHaveValue("Message before edit"); + + // Wait so the edit timestamp exceeds the 1s isEdited threshold + await page.waitForTimeout(1000); + + // Modify content and save + await imInput.fill("Message after edit"); + await saveButton.click(); + await page.waitForLoadState("networkidle"); + + // Verify the updated content appears + const updatedBubble = page + .locator(".thread-event--im") + .filter({ hasText: "Message after edit" }); + await expect(updatedBubble).toBeVisible(); + + // Verify the "(edited)" badge is shown + await expect( + updatedBubble.locator(".thread-event__edited-badge"), + ).toBeVisible(); + await expect( + updatedBubble.locator(".thread-event__edited-badge"), + ).toContainText("(edited)"); + + // Verify edit mode is dismissed + await expect( + page.locator(".thread-event-input__edit-banner"), + ).not.toBeVisible(); + await expect( + page + .locator(".thread-event-input") + .getByRole("button", { name: "Send" }), + ).toBeVisible(); + }); + + test("should cancel editing with Escape key", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Send a fresh message to edit then cancel + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Message to cancel edit"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Message to cancel edit" }); + await expect(bubble).toBeVisible(); + + // Hover and click Edit + await bubble.locator(".thread-event__bubble").hover(); + await bubble.getByRole("button", { name: "Edit" }).click(); + + // Verify we are in edit mode + await expect(page.locator(".thread-event-input__edit-banner")).toBeVisible(); + + await expect(imInput).toHaveValue("Message to cancel edit"); + + // Modify content but do NOT save + await imInput.fill("This should not be saved"); + + // Press Escape to cancel + await imInput.press("Escape"); + + // Verify edit mode is dismissed + await expect( + page.locator(".thread-event-input__edit-banner"), + ).not.toBeVisible(); + + // Verify Send button is back (not Save) + await expect( + page + .locator(".thread-event-input") + .getByRole("button", { name: "Send" }), + ).toBeVisible(); + + // Verify input is cleared + await expect(imInput).toHaveValue(""); + + // Verify the original message was NOT changed + await expect(bubble).toBeVisible(); + await expect( + page + .locator(".thread-event--im") + .filter({ hasText: "This should not be saved" }), + ).not.toBeVisible(); + }); + + test("should delete an IM after confirmation", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Send a message specifically for deletion + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Message to delete"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Message to delete" }); + await expect(bubble).toBeVisible(); + + // Hover and click Delete + await bubble.locator(".thread-event__bubble").hover(); + await bubble.getByRole("button", { name: "Delete" }).click(); + + // Confirmation modal appears + const confirmModal = page.getByRole("dialog"); + await expect(confirmModal).toBeVisible(); + await expect(confirmModal.getByText("Delete message")).toBeVisible(); + + // Confirm deletion + await confirmModal.getByRole("button", { name: "Delete" }).click(); + await page.waitForLoadState("networkidle"); + + // Verify the message is gone + await expect(bubble).not.toBeVisible(); + }); + + test("should render links in IM as clickable anchors", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Send a message containing a URL + const imInput = page.getByPlaceholder("Add internal comment..."); + await imInput.fill("Check https://example.com for details"); + await imInput.press("Enter"); + await page.waitForLoadState("networkidle"); + + // Locate the bubble + const bubble = page + .locator(".thread-event--im") + .filter({ hasText: "Check" }) + .filter({ hasText: "for details" }); + await expect(bubble).toBeVisible(); + + // Verify the URL is rendered as a clickable link + const link = bubble.locator("a[href='https://example.com']"); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute("target", "_blank"); + await expect(link).toHaveAttribute("rel", "noopener noreferrer"); + await expect(link).toHaveText("https://example.com"); + }); +}); diff --git a/src/e2e/src/types.ts b/src/e2e/src/types.ts new file mode 100644 index 000000000..4f40972a3 --- /dev/null +++ b/src/e2e/src/types.ts @@ -0,0 +1 @@ +export type BrowserName = "chromium" | "firefox" | "webkit"; diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index 737b78c64..b431611bf 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -16,8 +16,9 @@ RUN npm install FROM frontend-deps AS frontend-build +ARG DOCKER_USER=1000 WORKDIR /home/frontend/ -COPY . ./ +COPY --chown=${DOCKER_USER} . ./ ARG API_ORIGIN ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN} diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json old mode 100644 new mode 100755 index 97014eac9..1894cd84c --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -79,6 +79,7 @@ "Add a sub-label": "Add a sub-label", "Add attachment from {{driveAppName}}": "Add attachment from {{driveAppName}}", "Add attachments": "Add attachments", + "Add internal comment...": "Add internal comment...", "Add label": "Add label", "Add labels": "Add labels", "Add tags": "Add tags", @@ -110,6 +111,7 @@ "Are you sure you want to delete this integration? This action is irreversible!": "Are you sure you want to delete this integration? This action is irreversible!", "Are you sure you want to delete this label? This action is irreversible!": "Are you sure you want to delete this label? This action is irreversible!", "Are you sure you want to delete this mailbox? This action is irreversible!": "Are you sure you want to delete this mailbox? This action is irreversible!", + "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.", "Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!", "Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!", "Are you sure you want to reset the password?": "Are you sure you want to reset the password?", @@ -199,6 +201,7 @@ "Delete integration \"{{name}}\"": "Delete integration \"{{name}}\"", "Delete label \"{{label}}\"": "Delete label \"{{label}}\"", "Delete mailbox {{mailbox}}": "Delete mailbox {{mailbox}}", + "Delete message": "Delete message", "Delete signature \"{{signature}}\"": "Delete signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Delete template \"{{template}}\"", "Delivering": "Delivering", @@ -233,6 +236,8 @@ "Edit signature \"{{signature}}\"": "Edit signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Edit template \"{{template}}\"", "Edit Widget": "Edit Widget", + "edited": "edited", + "Editing message": "Editing message", "Email address": "Email address", "EML, MBOX or PST": "EML, MBOX or PST", "End date": "End date", @@ -369,6 +374,7 @@ "Message templates for {{mailbox}}": "Message templates for {{mailbox}}", "Messaging": "Messaging", "Missing": "Missing", + "Modified": "Modified", "Modify": "Modify", "Monday": "Monday", "Monthly": "Monthly", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json old mode 100644 new mode 100755 index 826ec33b3..001eed678 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -112,6 +112,7 @@ "Add a sub-label": "Ajouter un sous-libellé", "Add attachment from {{driveAppName}}": "Ajouter une pièce jointe depuis {{driveAppName}}", "Add attachments": "Ajoutez des pièces jointes", + "Add internal comment...": "Ajouter un commentaire interne...", "Add label": "Ajouter un libellé", "Add labels": "Ajouter des libellés", "Add tags": "Ajouter des libellés", @@ -144,6 +145,7 @@ "Are you sure you want to delete this integration? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette intégration ? Cette action est irréversible !", "Are you sure you want to delete this label? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce libellé ? Cette action est irréversible !", "Are you sure you want to delete this mailbox? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette boîte aux lettres ? Cette action est irréversible !", + "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce message ? Il sera supprimé pour tous les utilisateurs. Cette action ne peut pas être annulée.", "Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !", "Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !", "Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?", @@ -233,6 +235,7 @@ "Delete integration \"{{name}}\"": "Supprimer l'intégration \"{{name}}\"", "Delete label \"{{label}}\"": "Supprimer le libellé \"{{label}}\"", "Delete mailbox {{mailbox}}": "Supprimer la boîte aux lettres {{mailbox}}", + "Delete message": "Supprimer le message", "Delete signature \"{{signature}}\"": "Supprimer la signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Supprimer le modèle \"{{template}}\"", "Delivering": "En cours d'envoi", @@ -267,6 +270,8 @@ "Edit signature \"{{signature}}\"": "Modifier la signature \"{{signature}}\"", "Edit template \"{{template}}\"": "Modifier le modèle \"{{template}}\"", "Edit Widget": "Modifier le Widget", + "edited": "modifié", + "Editing message": "Modification du message", "Email address": "Adresse mail", "EML, MBOX or PST": "EML, MBOX ou PST", "End date": "Date de fin", @@ -408,6 +413,7 @@ "Message templates for {{mailbox}}": "Modèles de message de {{mailbox}}", "Messaging": "Messages", "Missing": "Manquant", + "Modified": "Modifié", "Modify": "Modifier", "Monday": "Lundi", "Monthly": "Mensuel", diff --git a/src/frontend/src/features/api/gen/index.ts b/src/frontend/src/features/api/gen/index.ts index 78e7f9be4..68388bf2e 100644 --- a/src/frontend/src/features/api/gen/index.ts +++ b/src/frontend/src/features/api/gen/index.ts @@ -15,6 +15,8 @@ export * from "./tasks/tasks"; export * from "./third-party-drive/third-party-drive"; export * from "./threads/threads"; export * from "./thread-access/thread-access"; +export * from "./thread-events/thread-events"; +export * from "./thread-users/thread-users"; export * from "./admin-users-list/admin-users-list"; export * from "./users/users"; export * from "./models"; diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 2ef830930..59d91ea33 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -65,6 +65,7 @@ export * from "./mailbox_access_write_request"; export * from "./mailbox_admin"; export * from "./mailbox_admin_create"; export * from "./mailbox_admin_create_metadata_request"; +export * from "./mailbox_admin_create_metadata_type_enum"; export * from "./mailbox_admin_create_payload_request"; export * from "./mailbox_admin_update_metadata_request"; export * from "./mailbox_light"; @@ -114,6 +115,9 @@ export * from "./patched_mailbox_admin_partial_update_payload_request"; export * from "./patched_message_template_request"; export * from "./patched_message_template_request_metadata"; export * from "./patched_thread_access_request"; +export * from "./patched_thread_event_request"; +export * from "./patched_thread_event_request_data_one_of"; +export * from "./patched_thread_event_request_data_one_of_mentions_item"; export * from "./placeholders_retrieve200"; export * from "./read_message_template"; export * from "./read_message_template_metadata"; @@ -135,6 +139,13 @@ export * from "./thread_access"; export * from "./thread_access_detail"; export * from "./thread_access_request"; export * from "./thread_access_role_choices"; +export * from "./thread_event"; +export * from "./thread_event_data_one_of"; +export * from "./thread_event_data_one_of_mentions_item"; +export * from "./thread_event_request"; +export * from "./thread_event_request_data_one_of"; +export * from "./thread_event_request_data_one_of_mentions_item"; +export * from "./thread_event_type_enum"; export * from "./thread_label"; export * from "./thread_split_request_request"; export * from "./threads_accesses_create_params"; @@ -151,7 +162,6 @@ export * from "./threads_stats_retrieve_params"; export * from "./threads_stats_retrieve_stats_fields"; export * from "./threads_summary_retrieve200"; export * from "./tree_label"; -export * from "./type_enum"; export * from "./user_with_abilities"; export * from "./user_with_abilities_abilities"; export * from "./user_with_abilities_custom_attributes"; diff --git a/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_request.ts b/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_request.ts index a8cd2c3f1..1601d40b3 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_request.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_request.ts @@ -5,10 +5,10 @@ * This is the messages API schema. * OpenAPI spec version: 1.0.0 (v1.0) */ -import type { TypeEnum } from "./type_enum"; +import type { MailboxAdminCreateMetadataTypeEnum } from "./mailbox_admin_create_metadata_type_enum"; export interface MailboxAdminCreateMetadataRequest { - type: TypeEnum; + type: MailboxAdminCreateMetadataTypeEnum; first_name?: string; last_name?: string; name?: string; diff --git a/src/frontend/src/features/api/gen/models/type_enum.ts b/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_type_enum.ts similarity index 65% rename from src/frontend/src/features/api/gen/models/type_enum.ts rename to src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_type_enum.ts index 08ae8aaa9..189232910 100644 --- a/src/frontend/src/features/api/gen/models/type_enum.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_admin_create_metadata_type_enum.ts @@ -11,10 +11,11 @@ * `shared` - shared * `redirect` - redirect */ -export type TypeEnum = (typeof TypeEnum)[keyof typeof TypeEnum]; +export type MailboxAdminCreateMetadataTypeEnum = + (typeof MailboxAdminCreateMetadataTypeEnum)[keyof typeof MailboxAdminCreateMetadataTypeEnum]; // eslint-disable-next-line @typescript-eslint/no-redeclare -export const TypeEnum = { +export const MailboxAdminCreateMetadataTypeEnum = { personal: "personal", shared: "shared", redirect: "redirect", diff --git a/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts b/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts new file mode 100644 index 000000000..1542e35ca --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_thread_event_request.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEventTypeEnum } from "./thread_event_type_enum"; +import type { PatchedThreadEventRequestDataOneOf } from "./patched_thread_event_request_data_one_of"; + +/** + * Serialize thread event information. + */ +export interface PatchedThreadEventRequest { + type?: ThreadEventTypeEnum; + /** + * primary key for the record as UUID + * @nullable + */ + message?: string | null; + data?: PatchedThreadEventRequestDataOneOf; +} diff --git a/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of.ts b/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of.ts new file mode 100644 index 000000000..97dff0906 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { PatchedThreadEventRequestDataOneOfMentionsItem } from "./patched_thread_event_request_data_one_of_mentions_item"; + +export type PatchedThreadEventRequestDataOneOf = { + content: string; + mentions?: PatchedThreadEventRequestDataOneOfMentionsItem[]; +}; diff --git a/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of_mentions_item.ts b/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of_mentions_item.ts new file mode 100644 index 000000000..583f5e3be --- /dev/null +++ b/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of_mentions_item.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type PatchedThreadEventRequestDataOneOfMentionsItem = { + id: string; + name: string; +}; diff --git a/src/frontend/src/features/api/gen/models/thread_event.ts b/src/frontend/src/features/api/gen/models/thread_event.ts new file mode 100644 index 000000000..fefffa537 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event.ts @@ -0,0 +1,37 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEventTypeEnum } from "./thread_event_type_enum"; +import type { UserWithoutAbilities } from "./user_without_abilities"; +import type { ThreadEventDataOneOf } from "./thread_event_data_one_of"; + +/** + * Serialize thread event information. + */ +export interface ThreadEvent { + /** primary key for the record as UUID */ + readonly id: string; + /** primary key for the record as UUID */ + readonly thread: string; + type: ThreadEventTypeEnum; + /** + * primary key for the record as UUID + * @nullable + */ + readonly channel: string | null; + /** + * primary key for the record as UUID + * @nullable + */ + message?: string | null; + readonly author: UserWithoutAbilities; + data: ThreadEventDataOneOf; + /** date and time at which a record was created */ + readonly created_at: string; + /** date and time at which a record was last updated */ + readonly updated_at: string; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_data_one_of.ts b/src/frontend/src/features/api/gen/models/thread_event_data_one_of.ts new file mode 100644 index 000000000..b79d0d69c --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_data_one_of.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEventDataOneOfMentionsItem } from "./thread_event_data_one_of_mentions_item"; + +export type ThreadEventDataOneOf = { + content: string; + mentions?: ThreadEventDataOneOfMentionsItem[]; +}; diff --git a/src/frontend/src/features/api/gen/models/thread_event_data_one_of_mentions_item.ts b/src/frontend/src/features/api/gen/models/thread_event_data_one_of_mentions_item.ts new file mode 100644 index 000000000..455bf8ba7 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_data_one_of_mentions_item.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type ThreadEventDataOneOfMentionsItem = { + id: string; + name: string; +}; diff --git a/src/frontend/src/features/api/gen/models/thread_event_request.ts b/src/frontend/src/features/api/gen/models/thread_event_request.ts new file mode 100644 index 000000000..747cef3a5 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_request.ts @@ -0,0 +1,22 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEventTypeEnum } from "./thread_event_type_enum"; +import type { ThreadEventRequestDataOneOf } from "./thread_event_request_data_one_of"; + +/** + * Serialize thread event information. + */ +export interface ThreadEventRequest { + type: ThreadEventTypeEnum; + /** + * primary key for the record as UUID + * @nullable + */ + message?: string | null; + data: ThreadEventRequestDataOneOf; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of.ts b/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of.ts new file mode 100644 index 000000000..37d6b2474 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import type { ThreadEventRequestDataOneOfMentionsItem } from "./thread_event_request_data_one_of_mentions_item"; + +export type ThreadEventRequestDataOneOf = { + content: string; + mentions?: ThreadEventRequestDataOneOfMentionsItem[]; +}; diff --git a/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of_mentions_item.ts b/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of_mentions_item.ts new file mode 100644 index 000000000..a1b791bdb --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of_mentions_item.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +export type ThreadEventRequestDataOneOfMentionsItem = { + id: string; + name: string; +}; diff --git a/src/frontend/src/features/api/gen/models/thread_event_type_enum.ts b/src/frontend/src/features/api/gen/models/thread_event_type_enum.ts new file mode 100644 index 000000000..09a595cb6 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_type_enum.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * * `im` - Instant message + */ +export type ThreadEventTypeEnum = + (typeof ThreadEventTypeEnum)[keyof typeof ThreadEventTypeEnum]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ThreadEventTypeEnum = { + im: "im", +} as const; diff --git a/src/frontend/src/features/api/gen/thread-events/thread-events.ts b/src/frontend/src/features/api/gen/thread-events/thread-events.ts new file mode 100644 index 000000000..2341d536c --- /dev/null +++ b/src/frontend/src/features/api/gen/thread-events/thread-events.ts @@ -0,0 +1,825 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { + PatchedThreadEventRequest, + ThreadEvent, + ThreadEventRequest, +} from ".././models"; + +import { fetchAPI } from "../../fetch-api"; +import type { ErrorType } from "../../fetch-api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsListResponse200 = { + data: ThreadEvent[]; + status: 200; +}; + +export type threadsEventsListResponseSuccess = threadsEventsListResponse200 & { + headers: Headers; +}; +export type threadsEventsListResponse = threadsEventsListResponseSuccess; + +export const getThreadsEventsListUrl = (threadId: string) => { + return `/api/v1.0/threads/${threadId}/events/`; +}; + +export const threadsEventsList = async ( + threadId: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsListUrl(threadId), + { + ...options, + method: "GET", + }, + ); +}; + +export const getThreadsEventsListQueryKey = (threadId?: string) => { + return [`/api/v1.0/threads/${threadId}/events/`] as const; +}; + +export const getThreadsEventsListQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getThreadsEventsListQueryKey(threadId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + threadsEventsList(threadId, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!threadId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ThreadsEventsListQueryResult = NonNullable< + Awaited> +>; +export type ThreadsEventsListQueryError = ErrorType; + +export function useThreadsEventsList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useThreadsEventsList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getThreadsEventsListQueryOptions(threadId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsCreateResponse201 = { + data: ThreadEvent; + status: 201; +}; + +export type threadsEventsCreateResponseSuccess = + threadsEventsCreateResponse201 & { + headers: Headers; + }; +export type threadsEventsCreateResponse = threadsEventsCreateResponseSuccess; + +export const getThreadsEventsCreateUrl = (threadId: string) => { + return `/api/v1.0/threads/${threadId}/events/`; +}; + +export const threadsEventsCreate = async ( + threadId: string, + threadEventRequest: ThreadEventRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsCreateUrl(threadId), + { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(threadEventRequest), + }, + ); +}; + +export const getThreadsEventsCreateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventRequest }, + TContext +> => { + const mutationKey = ["threadsEventsCreate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; data: ThreadEventRequest } + > = (props) => { + const { threadId, data } = props ?? {}; + + return threadsEventsCreate(threadId, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsCreateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsCreateMutationBody = ThreadEventRequest; +export type ThreadsEventsCreateMutationError = ErrorType; + +export const useThreadsEventsCreate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; data: ThreadEventRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsCreateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsRetrieveResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsRetrieveResponseSuccess = + threadsEventsRetrieveResponse200 & { + headers: Headers; + }; +export type threadsEventsRetrieveResponse = + threadsEventsRetrieveResponseSuccess; + +export const getThreadsEventsRetrieveUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsRetrieve = async ( + threadId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsRetrieveUrl(threadId, id), + { + ...options, + method: "GET", + }, + ); +}; + +export const getThreadsEventsRetrieveQueryKey = ( + threadId?: string, + id?: string, +) => { + return [`/api/v1.0/threads/${threadId}/events/${id}/`] as const; +}; + +export const getThreadsEventsRetrieveQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getThreadsEventsRetrieveQueryKey(threadId, id); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + threadsEventsRetrieve(threadId, id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!(threadId && id), + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ThreadsEventsRetrieveQueryResult = NonNullable< + Awaited> +>; +export type ThreadsEventsRetrieveQueryError = ErrorType; + +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + id: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useThreadsEventsRetrieve< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + id: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getThreadsEventsRetrieveQueryOptions( + threadId, + id, + options, + ); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} + +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsUpdateResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsUpdateResponseSuccess = + threadsEventsUpdateResponse200 & { + headers: Headers; + }; +export type threadsEventsUpdateResponse = threadsEventsUpdateResponseSuccess; + +export const getThreadsEventsUpdateUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsUpdate = async ( + threadId: string, + id: string, + threadEventRequest: ThreadEventRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsUpdateUrl(threadId, id), + { + ...options, + method: "PUT", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(threadEventRequest), + }, + ); +}; + +export const getThreadsEventsUpdateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext +> => { + const mutationKey = ["threadsEventsUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string; data: ThreadEventRequest } + > = (props) => { + const { threadId, id, data } = props ?? {}; + + return threadsEventsUpdate(threadId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsUpdateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsUpdateMutationBody = ThreadEventRequest; +export type ThreadsEventsUpdateMutationError = ErrorType; + +export const useThreadsEventsUpdate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string; data: ThreadEventRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsPartialUpdateResponse200 = { + data: ThreadEvent; + status: 200; +}; + +export type threadsEventsPartialUpdateResponseSuccess = + threadsEventsPartialUpdateResponse200 & { + headers: Headers; + }; +export type threadsEventsPartialUpdateResponse = + threadsEventsPartialUpdateResponseSuccess; + +export const getThreadsEventsPartialUpdateUrl = ( + threadId: string, + id: string, +) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsPartialUpdate = async ( + threadId: string, + id: string, + patchedThreadEventRequest: PatchedThreadEventRequest, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsPartialUpdateUrl(threadId, id), + { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(patchedThreadEventRequest), + }, + ); +}; + +export const getThreadsEventsPartialUpdateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext +> => { + const mutationKey = ["threadsEventsPartialUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string; data: PatchedThreadEventRequest } + > = (props) => { + const { threadId, id, data } = props ?? {}; + + return threadsEventsPartialUpdate(threadId, id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsPartialUpdateMutationResult = NonNullable< + Awaited> +>; +export type ThreadsEventsPartialUpdateMutationBody = PatchedThreadEventRequest; +export type ThreadsEventsPartialUpdateMutationError = ErrorType; + +export const useThreadsEventsPartialUpdate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string; data: PatchedThreadEventRequest }, + TContext +> => { + const mutationOptions = getThreadsEventsPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; +/** + * ViewSet for ThreadEvent model. + */ +export type threadsEventsDestroyResponse204 = { + data: void; + status: 204; +}; + +export type threadsEventsDestroyResponseSuccess = + threadsEventsDestroyResponse204 & { + headers: Headers; + }; +export type threadsEventsDestroyResponse = threadsEventsDestroyResponseSuccess; + +export const getThreadsEventsDestroyUrl = (threadId: string, id: string) => { + return `/api/v1.0/threads/${threadId}/events/${id}/`; +}; + +export const threadsEventsDestroy = async ( + threadId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsDestroyUrl(threadId, id), + { + ...options, + method: "DELETE", + }, + ); +}; + +export const getThreadsEventsDestroyMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationKey = ["threadsEventsDestroy"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string } + > = (props) => { + const { threadId, id } = props ?? {}; + + return threadsEventsDestroy(threadId, id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsDestroyMutationResult = NonNullable< + Awaited> +>; + +export type ThreadsEventsDestroyMutationError = ErrorType; + +export const useThreadsEventsDestroy = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationOptions = getThreadsEventsDestroyMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/api/gen/thread-users/thread-users.ts b/src/frontend/src/features/api/gen/thread-users/thread-users.ts new file mode 100644 index 000000000..085aab2fa --- /dev/null +++ b/src/frontend/src/features/api/gen/thread-users/thread-users.ts @@ -0,0 +1,204 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { UserWithoutAbilities } from ".././models"; + +import { fetchAPI } from "../../fetch-api"; +import type { ErrorType } from "../../fetch-api"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess). + */ +export type threadsUsersListResponse200 = { + data: UserWithoutAbilities[]; + status: 200; +}; + +export type threadsUsersListResponseSuccess = threadsUsersListResponse200 & { + headers: Headers; +}; +export type threadsUsersListResponse = threadsUsersListResponseSuccess; + +export const getThreadsUsersListUrl = (threadId: string) => { + return `/api/v1.0/threads/${threadId}/users/`; +}; + +export const threadsUsersList = async ( + threadId: string, + options?: RequestInit, +): Promise => { + return fetchAPI(getThreadsUsersListUrl(threadId), { + ...options, + method: "GET", + }); +}; + +export const getThreadsUsersListQueryKey = (threadId?: string) => { + return [`/api/v1.0/threads/${threadId}/users/`] as const; +}; + +export const getThreadsUsersListQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getThreadsUsersListQueryKey(threadId); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => threadsUsersList(threadId, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!threadId, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type ThreadsUsersListQueryResult = NonNullable< + Awaited> +>; +export type ThreadsUsersListQueryError = ErrorType; + +export function useThreadsUsersList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsUsersList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; +export function useThreadsUsersList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +}; + +export function useThreadsUsersList< + TData = Awaited>, + TError = ErrorType, +>( + threadId: string, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag; +} { + const queryOptions = getThreadsUsersListQueryOptions(threadId, options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey; + + return query; +} diff --git a/src/frontend/src/features/forms/components/combobox/index.tsx b/src/frontend/src/features/forms/components/combobox/index.tsx index 0f1957f0f..5d358e096 100644 --- a/src/frontend/src/features/forms/components/combobox/index.tsx +++ b/src/frontend/src/features/forms/components/combobox/index.tsx @@ -85,6 +85,7 @@ export const ComboBox = (props: ComboBoxProps) => { addSelectedItem(selectedItem); setInputValue(''); }, + defaultHighlightedIndex: 0, onInputValueChange: ({ inputValue: newInputValue }) => { const isPasted = newInputValue.length - inputValue?.length > 1 const [newItems, rest] = parseInputValue(newInputValue); diff --git a/src/frontend/src/features/layouts/components/thread-view/_index.scss b/src/frontend/src/features/layouts/components/thread-view/_index.scss index 9355e93e3..b08728182 100644 --- a/src/frontend/src/features/layouts/components/thread-view/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/_index.scss @@ -1,10 +1,12 @@ @use "../../../../styles/utils" as *; +@use "./components/thread-event/index" as *; +@use "./components/thread-event-input/index" as *; .thread-view { background-color: var(--c--contextuals--background--surface--tertiary); display: flex; flex-direction: column; - max-height: 100%; + height: 100%; overflow-y: auto; &--empty { @@ -48,6 +50,7 @@ right: 0; bottom: 0; z-index: 900; + height: auto; } .thread-view--talk .thread-message:not(.thread-message--sender), @@ -79,7 +82,7 @@ flex-direction: column; gap: var(--c--globals--spacings--base); padding: var(--c--globals--spacings--base); - margin-bottom: 300px; + flex: 1; } .thread-view__trashed-banner__content { diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss index 77a69e90c..be7d57e90 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/_index.scss @@ -203,7 +203,7 @@ } } -.calendar-invite__link { +.calendar-invite .calendar-invite__link { color: var(--c--contextuals--content--semantic--brand--primary); text-decoration: none; word-break: break-all; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx index b4cf0631f..5aabadf60 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/calendar-helper.tsx @@ -30,30 +30,7 @@ export function getEventEnd(event: IcsEvent): Date | undefined { return undefined; } -/** - * Convert URL strings in text to clickable links - */ -export function linkifyText(text: string): React.ReactNode[] { - const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i; - const parts = text.split(urlRegex); - - return parts.map((part, index) => { - if (urlRegex.test(part)) { - return ( - - {part} - - ); - } - return part; - }); -} +export { TextHelper } from "@/features/utils/text-helper"; /** * Detect all-day events by checking if start/end are both at midnight. diff --git a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx index 11f1d6c55..b58f389d0 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/calendar-invite/index.tsx @@ -15,7 +15,7 @@ import { ContactChip } from "@/features/ui/components/contact-chip"; import { Badge } from "@/features/ui/components/badge"; import { getEventEnd, - linkifyText, + TextHelper, formatEventDateRange, formatRecurrenceRule, getAttendeeStatusInfo, @@ -165,7 +165,12 @@ const EventCard = ({ type={IconType.OUTLINED} className="calendar-invite__detail-icon" /> - {linkifyText(event.location)} + + {TextHelper.renderLinks( + [event.location], + { props: { className: "calendar-invite__link" } } + )} + )} @@ -195,7 +200,7 @@ const EventCard = ({ className="calendar-invite__detail-icon" />
-

{linkifyText(displayedDescription)}

+

{TextHelper.renderLinks([displayedDescription])}

{descriptionTruncated && (
+ )} +
+ { + if (!open) { + setShowMentionPopover(false); + setMentionFilter(""); + } + }} + inputValue={mentionFilter} + onSelect={insertMention} + itemToString={(item) => item?.full_name || item?.email || ""} + keyExtractor={(user) => user.id} + renderItem={(user) => ( + + )} + /> +
+ + ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss new file mode 100755 index 000000000..71d72cd1a --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss @@ -0,0 +1,160 @@ +.thread-event { + display: flex; + gap: var(--c--globals--spacings--sm); + justify-content: flex-start; + + &--generic { + justify-content: center; + } +} + +.thread-event--im .thread-event__bubble { + max-width: 85ch; + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--3xs); + position: relative; +} + +.thread-event--im .thread-event__header { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); +} + +.thread-event--im .thread-event__author { + font-weight: 600; + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--primary); + display: flex; + align-items: center; + gap: var(--c--globals--spacings--2xs); +} + +.thread-event--im .thread-event__time { + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--secondary); +} + +.thread-event--im .thread-event__content { + color: var(--c--contextuals--content--semantic--neutral--primary); + font-size: var(--c--globals--font--sizes--sm); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm); + margin-inline: var(--c--globals--spacings--xs); + transition: transform 0.2s ease; + background-color: var(--c--contextuals--background--semantic--neutral--secondary); + border-radius: var(--c--globals--spacings--3xs) var(--c--globals--spacings--base) var(--c--globals--spacings--base) var(--c--globals--spacings--base); +} + +// Condensed: consecutive IMs from same author within 2 min +.thread-event--condensed { + margin-top: calc(-1 * var(--c--globals--spacings--xs)); + + // Stacked bubbles get uniform border-radius + .thread-event__content { + border-radius: var(--c--globals--spacings--base); + } +} + +.thread-event__mention { + font-weight: 600; + text-decoration: underline; + + &--highlight { + background-color: var(--c--contextuals--background--semantic--warning--secondary); + border-radius: var(--c--globals--spacings--3xs); + padding-inline: var(--c--globals--spacings--3xs); + } +} + +.thread-event__content a { + color: var(--c--contextuals--content--semantic--info--primary); + text-decoration: underline; + word-break: break-all; + + &:hover { + text-decoration: none; + } +} + +// Actions popover (visible on hover, positioned to the left of the bubble) +.thread-event__actions { + position: absolute; + bottom: 0; + left: 0; + display: flex; + gap: var(--c--globals--spacings--4xs); + background-color: var(--c--contextuals--background--surface--primary); + border: 1px solid var(--c--contextuals--border--default); + border-radius: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--4xs); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +// Long-press feedback: scale down the content bubble while pressing +.thread-event__content--pressing { + transform: scale(0.97); +} + +.thread-event__bubble:hover > .thread-event__actions, +.thread-event__actions:hover, +.thread-event__bubble--actions-visible > .thread-event__actions { + opacity: 1; + pointer-events: auto; +} + +// Edited badge +.thread-event__edited-badge { + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--tertiary); + font-style: italic; + margin-left: var(--c--globals--spacings--3xs); +} + +// Generic event types (non-IM) +.thread-event--generic { + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base); +} + +.thread-event--generic .thread-event__body { + flex: 1; + min-width: 0; +} + +.thread-event--generic .thread-event__header { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + margin-bottom: var(--c--globals--spacings--3xs); +} + +.thread-event--generic .thread-event__time { + font-size: var(--c--globals--font--sizes--xs); + color: var(--c--contextuals--content--semantic--neutral--tertiary); +} + +.thread-event--generic .thread-event__content { + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--content--semantic--neutral--secondary); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.thread-event__badge { + flex-shrink: 0; + padding: 2px var(--c--globals--spacings--xs); + border-radius: var(--c--globals--spacings--3xs); + background-color: var(--c--contextuals--content--semantic--neutral--tertiary); + color: white; + font-size: var(--c--globals--font--sizes--xs); + font-weight: 600; + text-transform: uppercase; + height: fit-content; +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx new file mode 100755 index 000000000..6e692e493 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { TextHelper } from "@/features/utils/text-helper"; +import { ThreadEvent as ThreadEventType, ThreadEventTypeEnum } from "@/features/api/gen/models"; +import { useThreadsEventsDestroy } from "@/features/api/gen/thread-events/thread-events"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/features/auth"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { AVATAR_COLORS, Icon, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit"; +import { Button, useModals } from "@gouvfr-lasuite/cunningham-react"; + +const TWO_MINUTES_MS = 2 * 60 * 1000; + +/** + * Computes the avatar palette color for a given name. + * Mirrors the hash logic used by UserAvatar from @gouvfr-lasuite/ui-kit. + */ +const getAvatarColor = (name: string): string => { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash += name.charCodeAt(i); + } + return AVATAR_COLORS[hash % AVATAR_COLORS.length]; +}; + +type ThreadEventProps = { + event: ThreadEventType; + previousEvent?: ThreadEventType | null; + onEdit?: (event: ThreadEventType) => void; + onDelete?: (eventId: string) => void; +}; + +const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +/** + * Returns true if this IM event should show a condensed view (no header), + * because the previous event is also an IM from the same author within 2 minutes. + */ +const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | null): boolean => { + if (!previousEvent) return false; + if (event.type !== ThreadEventTypeEnum.im || previousEvent.type !== ThreadEventTypeEnum.im) return false; + if (event.author?.id !== previousEvent.author?.id) return false; + const diff = new Date(event.created_at).getTime() - new Date(previousEvent.created_at).getTime(); + return Math.abs(diff) < TWO_MINUTES_MS; +}; + +/** + * Renders a thread event in the timeline. + * For type=im: renders as a chat bubble with avatar, author name, and content. + * Consecutive IMs from the same author within 2 minutes are condensed (no header). + * For other types: renders a minimal card with type badge and data. + */ +export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEventProps) => { + const { t } = useTranslation(); + const { user } = useAuth(); + const modals = useModals(); + const { invalidateThreadEvents } = useMailboxContext(); + const content = event.data?.content ?? ""; + const condensed = isCondensed(event, previousEvent); + const isSender = event.author?.id === user?.id; + const isEdited = Math.abs(new Date(event.updated_at).getTime() - new Date(event.created_at).getTime()) > 1000; + + const deleteEvent = useThreadsEventsDestroy(); + const [showActions, setShowActions] = useState(false); + const longPressTimer = useRef | null>(null); + const bubbleRef = useRef(null); + + const [pressing, setPressing] = useState(false); + + const handleTouchStart = useCallback(() => { + setPressing(true); + longPressTimer.current = setTimeout(() => { + setPressing(false); + setShowActions(true); + navigator.vibrate?.(50); + }, 500); + }, []); + + const cancelLongPress = useCallback(() => { + setPressing(false); + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + useEffect(() => { + if (!showActions) return; + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (bubbleRef.current && !bubbleRef.current.contains(e.target as Node)) { + setShowActions(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [showActions]); + + const handleDelete = async () => { + const decision = await modals.deleteConfirmationModal({ + title: {t('Delete message')}, + children: t('Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.'), + }); + if (decision !== 'delete') return; + deleteEvent.mutate( + { threadId: event.thread, id: event.id }, + { onSuccess: () => { + onDelete?.(event.id); + invalidateThreadEvents(); + } }, + ); + }; + + if (event.type === ThreadEventTypeEnum.im) { + const authorName = event.author?.full_name || event.author?.email || ""; + const avatarColor = getAvatarColor(authorName); + const isMentioned = user + ? event.data?.mentions?.map((m) => m.id)?.includes(user.id) + : false; + + const imClasses = [ + "thread-event", + "thread-event--im", + condensed && "thread-event--condensed", + isSender && "thread-event--sender", + ].filter(Boolean).join(" "); + + const bubbleStyle = { + "--thread-event-color": `var(--c--contextuals--background--palette--${avatarColor}--primary)`, + } as React.CSSProperties; + + return ( +
+
+ {!condensed && ( +
+ + + {event.author?.full_name || event.author?.email || t("Unknown")} + + + {formatTime(event.created_at)} + +
+ )} +
+ {TextHelper.renderLinks( + TextHelper.renderMentions( + content, + isMentioned ? user?.full_name ?? undefined : undefined, + { baseClassName: "thread-event" } + ) + )} + {isEdited && ( + ({t("edited")}) + )} +
+ {isSender && ( +
+
+ )} +
+
+ ); + } + + // Fallback for other event types + return ( +
+
{event.type}
+
+
+ + {formatTime(event.created_at)} + {isEdited && ` · ${t('Modified')}`} + +
+ {content && ( +
{content}
+ )} +
+
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/index.tsx b/src/frontend/src/features/layouts/components/thread-view/index.tsx old mode 100644 new mode 100755 index 377b53994..6d2d406d4 --- a/src/frontend/src/features/layouts/components/thread-view/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/index.tsx @@ -1,11 +1,13 @@ -import React, { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature"; import { ThreadActionBar } from "./components/thread-action-bar" import { ThreadMessage } from "./components/thread-message" -import { useMailboxContext } from "@/features/providers/mailbox" +import { ThreadEvent } from "./components/thread-event" +import { ThreadEventInput } from "./components/thread-event-input" +import { useMailboxContext, TimelineItem, isThreadEvent } from "@/features/providers/mailbox" import useRead from "@/features/message/use-read" import { useDebounceCallback } from "@/hooks/use-debounce-callback" -import { Message, Thread } from "@/features/api/gen/models" +import { Message, Thread, ThreadAccessRoleChoices, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models" import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit" import { Banner } from "@/features/ui/components/banner" import { SKIP_LINK_TARGET_ID } from "@/features/ui/components/skip-link" @@ -22,19 +24,21 @@ type MessageWithDraftChild = Message & { } type ThreadViewComponentProps = { - messages: readonly MessageWithDraftChild[], + threadItems: readonly TimelineItem[], mailboxId: string, thread: Thread, showTrashedMessages: boolean, setShowTrashedMessages: (show: boolean) => void, stats: { trashed: number, archived: number, total: number }, + showIMInput: boolean, } -const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, setShowTrashedMessages, stats }: ThreadViewComponentProps) => { +const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessages, setShowTrashedMessages, stats, showIMInput }: ThreadViewComponentProps) => { const { t } = useTranslation(); const latestSeenDate = useRef(null); const stickyContainerRef = useRef(null); const { markAsReadAt } = useRead(); + const [editingEvent, setEditingEvent] = useState(null); const { markAsNotSpam } = useSpam(); const debouncedMarkAsRead = useDebounceCallback((threadId: string, readAt: string) => { markAsReadAt({ threadIds: [threadId], readAt }); @@ -46,8 +50,9 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, // Refs for all unread messages const unreadRefs = useRef>({}); // Find all unread message IDs - const unreadMessageIds = messages.filter((m) => m.is_unread).map((m) => m.id) || []; - const draftMessageIds = messages.filter((m) => m.draft_message).map((m) => m.id) || []; + const messages = useMemo(() => threadItems.filter(item => item.type === 'message').map(item => item.data as MessageWithDraftChild), [threadItems]); + const unreadMessageIds = useMemo(() => messages.filter((m) => m.is_unread).map((m) => m.id), [messages]); + const draftMessageIds = useMemo(() => messages.filter((m) => m.draft_message).map((m) => m.id), [messages]); const isThreadTrashed = stats.trashed === stats.total; const isThreadArchived = stats.archived === stats.total; const isThreadSender = messages?.some((m) => m.is_sender); @@ -58,6 +63,18 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, return acc; }, messages[0]); + /** + * Scroll to the bottom of the thread view. + */ + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + rootRef.current?.scrollTo({ + top: rootRef.current.scrollHeight, + behavior: 'smooth', + }); + }); + }, []); + /** * Setup an intersection observer to mark messages as read when they are * scrolled into view. @@ -115,8 +132,15 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, } }, [isReady]); + const handleEventDelete = useCallback((eventId: string) => { + if (editingEvent?.id === eventId) { + setEditingEvent(null); + } + }, [editingEvent]); + useEffect(() => () => { reset(); + setEditingEvent(null); }, [thread.id]); return ( @@ -160,8 +184,7 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, {stats.trashed > 0 && !showTrashedMessages && ( } - type="info" - actions={[{ + type="info" actions={[{ label: t('Show'), onClick: () => setShowTrashedMessages(!showTrashedMessages) }]} @@ -175,31 +198,43 @@ const ThreadViewComponent = ({ messages, mailboxId, thread, showTrashedMessages, )} )} - {(() => { - return messages.map((message) => { - const isLatest = latestMessage?.id === message.id; - const isUnread = message.is_unread; - return ( - { unreadRefs.current[message.id] = el; }) : undefined} - data-message-id={message.id} - data-created-at={message.created_at} - draftMessage={message.draft_message} - /> - ); - }); - })()} + {threadItems.map((item, index) => { + if (isThreadEvent(item)) { + const prevItem = index > 0 ? threadItems[index - 1] : null; + const prevEvent = isThreadEvent(prevItem) ? prevItem.data : null; + return ; + } + const message = item.data as MessageWithDraftChild; + const isLatest = latestMessage?.id === message.id; + const isUnread = message.is_unread; + return ( + { unreadRefs.current[message.id] = el; }) : undefined} + data-message-id={message.id} + data-created-at={message.created_at} + draftMessage={message.draft_message} + /> + ); + })} + {showIMInput && ( + setEditingEvent(null)} + onEventCreated={scrollToBottom} + /> + )} ) } export const ThreadView = () => { const isTrashView = ViewHelper.isTrashedView(); - const { selectedMailbox, selectedThread, messages, queryStates } = useMailboxContext(); + const { selectedMailbox, selectedThread, messages, threadItems, queryStates } = useMailboxContext(); const [showTrashedMessages, setShowTrashedMessages] = useState(isTrashView); // Nest draft messages under their parent messages const messagesWithDraftChildren = useMemo(() => { @@ -219,17 +254,34 @@ export const ThreadView = () => { archived: messagesWithDraftChildren?.filter((m) => m.is_archived).length || 0, total: messagesWithDraftChildren?.length || 0, }), [messagesWithDraftChildren]); - /** - * If we are in the trash view, we only want to show trashed messages - * otherwise, we want to show only non-trashed messages - * - * If we are not in the trash view and the user has clicked on the "show trashed messages" button, - * we want to show all messages. - */ - const filteredMessages = useMemo(() => { - if (!isTrashView && showTrashedMessages) return messagesWithDraftChildren; - return messagesWithDraftChildren.filter((m) => m.is_trashed === isTrashView); - }, [messagesWithDraftChildren, isTrashView, showTrashedMessages]); + // Show IM input for shared mailboxes, or when the current mailbox + // has editor access on a thread shared with other mailboxes. + const hasEditorAccess = selectedThread?.accesses?.some( + access => access.mailbox.id === selectedMailbox?.id + && access.role === ThreadAccessRoleChoices.editor + ); + const hasMultipleAccesses = (selectedThread?.accesses?.length ?? 0) > 1; + const isSharedMailbox = selectedMailbox?.is_identity === false; + const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasEditorAccess); + + // Build filtered timeline items: enrich messages with draft children, + // apply trash filtering, and keep all events. + const filteredThreadItems = useMemo(() => { + if (!threadItems) return []; + const messagesById = new Map(messagesWithDraftChildren.map((m) => [m.id, m])); + const showAll = !isTrashView && showTrashedMessages; + return threadItems.flatMap((item) => { + if (item.type === 'event') return [item]; + const message = messagesById.get(item.data.id); + if (!message) return []; + if (!showAll && message.is_trashed !== isTrashView) return []; + return [{ type: 'message', data: message, created_at: item.created_at }]; + }); + }, [threadItems, messagesWithDraftChildren, isTrashView, showTrashedMessages]); + + const messageIds = filteredThreadItems + .filter((item): item is Extract => item.type === 'message') + .map(item => item.data.id); useEffect(() => () => { setShowTrashedMessages(isTrashView); @@ -237,7 +289,7 @@ export const ThreadView = () => { if (!selectedMailbox || !selectedThread) return null - if (queryStates.messages.isLoading) { + if (queryStates.messages.isLoading || queryStates.threadEvents.isLoading) { return (
@@ -246,14 +298,15 @@ export const ThreadView = () => { } return ( - m.id) || []}> + ) diff --git a/src/frontend/src/features/providers/mailbox.tsx b/src/frontend/src/features/providers/mailbox.tsx index 46b61ac45..4678ce70e 100644 --- a/src/frontend/src/features/providers/mailbox.tsx +++ b/src/frontend/src/features/providers/mailbox.tsx @@ -1,5 +1,5 @@ import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useRef } from "react"; -import { Mailbox, MailboxRoleChoices, Message, messagesListResponse200, PaginatedThreadList, Thread, useLabelsList, useMailboxesList, useMessagesList, useThreadsListInfinite } from "../api/gen"; +import { Mailbox, MailboxRoleChoices, Message, messagesListResponse200, PaginatedThreadList, Thread, ThreadEvent, useLabelsList, useMailboxesList, useMessagesList, useThreadsEventsList, useThreadsListInfinite, getThreadsEventsListQueryKey } from "../api/gen"; import { FetchStatus, InfiniteData, QueryStatus, RefetchOptions, useQueryClient } from "@tanstack/react-query"; import type { threadsListResponse } from "../api/gen/threads/threads"; import { useRouter } from "next/router"; @@ -36,15 +36,22 @@ type MessageQueryInvalidationSource = { skipThreadsRefetch?: boolean; } +export type TimelineItem = + | { type: 'message'; data: Message; created_at: string } + | { type: 'event'; data: ThreadEvent; created_at: string }; + type MailboxContextType = { mailboxes: readonly Mailbox[] | null; threads: PaginatedThreadList | null; messages: readonly Message[] | null; + threadEvents: readonly ThreadEvent[] | null; + threadItems: readonly TimelineItem[] | null; selectedMailbox: Mailbox | null; selectedThread: Thread | null; unselectThread: () => void; loadNextThreads: () => Promise; invalidateThreadMessages: (source?: MessageQueryInvalidationSource) => Promise; + invalidateThreadEvents: () => Promise; invalidateThreadsStats: () => Promise; invalidateLabels: () => Promise; refetchMailboxes: (options?: RefetchOptions) => Promise; @@ -53,23 +60,30 @@ type MailboxContextType = { mailboxes: QueryState, threads: PaginatedQueryState, messages: QueryState, + threadEvents: QueryState, }; error: { mailboxes: unknown | null; threads: unknown | null; messages: unknown | null; + threadEvents: unknown | null; }; } +export const isThreadEvent = (item: TimelineItem | null): item is Extract => item?.type === 'event'; + const MailboxContext = createContext({ mailboxes: null, threads: null, messages: null, + threadEvents: null, + threadItems: null, selectedMailbox: null, selectedThread: null, loadNextThreads: async () => {}, unselectThread: () => {}, invalidateThreadMessages: async () => {}, + invalidateThreadEvents: async () => {}, invalidateThreadsStats: async () => {}, invalidateLabels: async () => {}, refetchMailboxes: async () => {}, @@ -94,11 +108,18 @@ const MailboxContext = createContext({ isFetching: false, isLoading: false, }, + threadEvents: { + status: 'pending', + fetchStatus: 'idle', + isFetching: false, + isLoading: false, + }, }, error: { mailboxes: null, threads: null, messages: null, + threadEvents: null, }, }); @@ -287,6 +308,29 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { } }); + const threadEventsQuery = useThreadsEventsList(selectedThread?.id ?? '', { + query: { + enabled: !!selectedThread, + }, + }); + + const threadItems = useMemo(() => { + if (!messagesQuery.data?.data) return null; + const messageItems: TimelineItem[] = messagesQuery.data.data.map((m) => ({ + type: 'message' as const, + data: m, + created_at: m.created_at, + })); + const eventItems: TimelineItem[] = (threadEventsQuery.data?.data ?? []).map((e) => ({ + type: 'event' as const, + data: e, + created_at: e.created_at, + })); + return [...messageItems, ...eventItems].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + }, [messagesQuery.data, threadEventsQuery.data?.data]); + const labelsQuery = useLabelsList({ mailbox_id: selectedMailbox?.id ?? '' }, { query: { enabled: !!selectedMailbox, @@ -462,6 +506,13 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { await queryClient.invalidateQueries({ queryKey: ['messages', selectedThread.id] }); } } + + const invalidateThreadEvents = async () => { + if (selectedThread) { + await queryClient.invalidateQueries({ queryKey: getThreadsEventsListQueryKey(selectedThread.id) }); + } + } + const invalidateThreadsStats = async () => { await queryClient.invalidateQueries({ queryKey: ['threads', 'stats', selectedMailbox?.id], @@ -489,11 +540,14 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { mailboxes: mailboxQuery.data?.data ?? null, threads: flattenThreads ?? null, messages: messagesQuery.data?.data ?? null, + threadEvents: threadEventsQuery.data?.data ?? null, + threadItems: threadItems, selectedMailbox, selectedThread, unselectThread, loadNextThreads: threadsQuery.fetchNextPage, invalidateThreadMessages, + invalidateThreadEvents, invalidateThreadsStats, invalidateLabels, refetchMailboxes: mailboxQuery.refetch, @@ -519,11 +573,18 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { isFetching: messagesQuery.isFetching, isLoading: messagesQuery.isLoading, }, + threadEvents: { + status: threadEventsQuery.status, + fetchStatus: threadEventsQuery.fetchStatus, + isFetching: threadEventsQuery.isFetching, + isLoading: threadEventsQuery.isLoading, + }, }, error: { mailboxes: mailboxQuery.error, threads: threadsQuery.error, messages: messagesQuery.error, + threadEvents: threadEventsQuery.error, }, }; diff --git a/src/frontend/src/features/ui/components/suggestion-input/_index.scss b/src/frontend/src/features/ui/components/suggestion-input/_index.scss new file mode 100644 index 000000000..2ca06badf --- /dev/null +++ b/src/frontend/src/features/ui/components/suggestion-input/_index.scss @@ -0,0 +1,35 @@ +.suggestion-input { + position: relative; +} + +.suggestion-input__popover { + display: none; + position: absolute; + bottom: 100%; + left: 2px; + min-width: calc(100% - 4px); + max-height: 20rem; + overflow-y: auto; + background-color: var(--c--components--forms-select--menu-background-color); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + list-style-type: none; + padding: 0; + margin: 0; + z-index: 10; + + &--open { + display: block; + } +} + +.suggestion-input__item { + padding: var(--c--globals--spacings--sm); + font-size: var(--c--components--forms-select--item-font-size); + color: var(--c--components--forms-select--item-color); + cursor: pointer; + + &:hover, + &--highlighted { + background-color: var(--c--components--forms-select--item-background-color--hover); + } +} diff --git a/src/frontend/src/features/ui/components/suggestion-input/index.tsx b/src/frontend/src/features/ui/components/suggestion-input/index.tsx new file mode 100644 index 000000000..f1e9ffa54 --- /dev/null +++ b/src/frontend/src/features/ui/components/suggestion-input/index.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef } from "react"; +import { useCombobox } from "downshift"; +import clsx from "clsx"; + +type SuggestionInputProps = { + /** Render as input or textarea */ + as?: "input" | "textarea"; + /** Current value of the input */ + value: string; + /** onChange handler for the input */ + onChange: (e: React.ChangeEvent) => void; + /** Additional keydown handler — called when the popover doesn't consume the event */ + onKeyDown?: (e: React.KeyboardEvent) => void; + placeholder?: string; + rows?: number; + inputRef?: React.RefObject; + inputClassName?: string; + + /** Items to display in the suggestion popover */ + items: T[]; + /** Whether the popover is open (controlled) */ + isOpen: boolean; + /** Called when the popover should close (click outside, Escape, item selection) */ + onOpenChange?: (isOpen: boolean) => void; + /** Filter text passed to downshift's inputValue */ + inputValue: string; + /** Called when an item is selected from the popover */ + onSelect: (item: T) => void; + /** Convert item to string (used by downshift internally) */ + itemToString: (item: T | null) => string; + /** Render each popover item */ + renderItem: (item: T, highlighted: boolean) => React.ReactNode; + /** Extract a unique key from an item */ + keyExtractor: (item: T) => string; + + /** Container className (merged with "suggestion-input") */ + className?: string; +}; + +/** + * Generic input (text or textarea) with a suggestion popover. + * Handles keyboard navigation, ARIA attributes, and click-outside via downshift. + * The parent controls open/close state and provides items. + */ +export function SuggestionInput({ + as: inputAs = "textarea", + value, + onChange, + onKeyDown, + placeholder, + rows, + inputRef: externalInputRef, + inputClassName, + items, + isOpen, + onOpenChange, + inputValue, + onSelect, + itemToString, + renderItem, + keyExtractor, + className, +}: SuggestionInputProps) { + const internalInputRef = useRef(null); + const inputRef = externalInputRef ?? internalInputRef; + const popoverRef = useRef(null); + + // Stable ref to avoid re-creating click-outside listener on every render + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + + const { + getInputProps, + getMenuProps, + getItemProps, + highlightedIndex, + selectItem, + } = useCombobox({ + items, + inputValue, + isOpen, + itemToString, + onSelectedItemChange: ({ selectedItem }) => { + if (selectedItem != null) { + onSelect(selectedItem); + // Reset so the same item can be re-selected after deletion + selectItem(null as T); + } + }, + onIsOpenChange: ({ isOpen: newIsOpen }) => { + if (!newIsOpen) { + onOpenChangeRef.current?.(false); + } + }, + defaultHighlightedIndex: 0, + stateReducer: (state, actionAndChanges) => { + const { type, changes } = actionAndChanges; + switch (type) { + // Keep our controlled isOpen — we open/close based on parent's detection logic + case useCombobox.stateChangeTypes.InputChange: + return { ...changes, isOpen: state.isOpen }; + default: + return changes; + } + }, + }); + + // We only use downshift's input props for ARIA attributes and keyboard delegation. + // We don't spread them directly because downshift's onKeyDown unconditionally + // prevents default on ArrowDown/ArrowUp, breaking normal cursor navigation + // when the popover is closed. + const downshiftInputProps = getInputProps({ suppressRefError: true }); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isOpen) { + const isNavKey = e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Escape"; + const isSelectKey = e.key === "Enter" && !e.shiftKey && highlightedIndex >= 0; + + if (isNavKey || isSelectKey) { + (downshiftInputProps.onKeyDown as React.KeyboardEventHandler)(e); + return; + } + } + + onKeyDown?.(e); + }; + + // Close popover on click outside input and popover + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if ( + popoverRef.current && !popoverRef.current.contains(target) && + inputRef.current && !inputRef.current.contains(target) + ) { + onOpenChangeRef.current?.(false); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, inputRef]); + + const menuProps = getMenuProps({ ref: popoverRef }); + + const sharedInputProps = { + className: clsx("suggestion-input__input", inputClassName), + value, + onChange, + onKeyDown: handleKeyDown, + placeholder, + role: "combobox" as const, + "aria-autocomplete": "list" as const, + "aria-controls": downshiftInputProps["aria-controls"], + "aria-expanded": isOpen, + "aria-activedescendant": downshiftInputProps["aria-activedescendant"], + }; + + return ( +
+ {inputAs === "input" ? ( + } + {...sharedInputProps} + /> + ) : ( +