From 962fa58688f09c2abecb63d2c4357dad94970bfa Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Wed, 22 Apr 2026 11:10:19 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(global)=20allow=20thread=20assignatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As a follow-up of the mention feature, we build upon ThreadEvent & UserEvent models a feature to assign users to a thread. We allow to filter mailboxe's inbox through assignation state (assigned to me, unassigned). The thread share modal has been forked from ui-kit to be able to list users of each mailbox and add a cta to assign them to the thread. A section above shows assigned users. --- docs/permissions.md | 61 +- src/backend/core/admin.py | 251 ++++- src/backend/core/api/openapi.json | 389 +++++--- src/backend/core/api/permissions.py | 31 +- src/backend/core/api/serializers.py | 255 ++++- .../core/api/viewsets/mailbox_access.py | 33 +- src/backend/core/api/viewsets/thread.py | 107 +- .../core/api/viewsets/thread_access.py | 33 +- src/backend/core/api/viewsets/thread_event.py | 87 +- src/backend/core/api/viewsets/thread_user.py | 17 +- src/backend/core/enums.py | 10 + src/backend/core/factories.py | 41 +- ...serevent_usrevt_user_thread_assign_uniq.py | 17 + src/backend/core/models.py | 184 ++-- src/backend/core/services/thread_events.py | 650 +++++++++++++ src/backend/core/signals.py | 153 --- src/backend/core/tests/api/test_mailboxes.py | 54 ++ .../tests/api/test_provisioning_mailbox.py | 9 +- .../core/tests/api/test_thread_access.py | 203 +++- .../core/tests/api/test_thread_event.py | 917 ++++++++++++++++++ .../api/test_thread_filter_assignment.py | 397 ++++++++ .../core/tests/api/test_thread_user.py | 131 ++- .../core/tests/api/test_threads_list.py | 84 ++ .../core/tests/models/test_thread_event.py | 95 ++ .../core/tests/models/test_user_event.py | 125 +++ .../core/tests/services/test_thread_events.py | 744 ++++++++++++++ src/backend/core/utils.py | 17 + src/e2e/src/__tests__/thread-event.spec.ts | 246 +++++ src/frontend/public/locales/common/en-US.json | 76 +- src/frontend/public/locales/common/fr-FR.json | 98 +- .../src/features/api/gen/models/index.ts | 17 +- .../src/features/api/gen/models/mailbox.ts | 6 + .../features/api/gen/models/mailbox_light.ts | 2 + .../models/paginated_thread_access_list.ts | 17 - .../models/patched_thread_event_request.ts | 4 +- ...atched_thread_event_request_data_one_of.ts | 13 - ...event_request_data_one_of_mentions_item.ts | 12 - .../src/features/api/gen/models/thread.ts | 2 + .../features/api/gen/models/thread_access.ts | 2 + .../features/api/gen/models/thread_event.ts | 4 +- .../gen/models/thread_event_assignees_data.ts | 23 + .../thread_event_assignees_data_request.ts | 23 + .../api/gen/models/thread_event_data.ts | 11 + .../gen/models/thread_event_data_one_of.ts | 13 - .../thread_event_data_one_of_mentions_item.ts | 12 - .../gen/models/thread_event_data_request.ts | 13 + .../api/gen/models/thread_event_im_data.ts | 21 + .../models/thread_event_im_data_request.ts | 22 + .../api/gen/models/thread_event_request.ts | 4 +- .../thread_event_request_data_one_of.ts | 13 - .../api/gen/models/thread_event_type_enum.ts | 4 + .../api/gen/models/thread_event_user.ts | 21 + .../gen/models/thread_event_user_request.ts | 22 + .../api/gen/models/thread_mentionable_user.ts | 23 + ...ead_mentionable_user_custom_attributes.ts} | 8 +- .../models/threads_accesses_list_params.ts | 4 - .../api/gen/models/threads_list_params.ts | 8 + .../models/threads_stats_retrieve_params.ts | 10 +- .../threads_stats_retrieve_stats_fields.ts | 2 + .../api/gen/thread-access/thread-access.ts | 3 +- .../api/gen/thread-events/thread-events.ts | 10 +- .../api/gen/thread-users/thread-users.ts | 4 +- .../components/mailbox-list/index.tsx | 94 +- .../components/thread-item/_index.scss | 1 + .../components/thread-item/index.tsx | 22 +- .../components/thread-panel-filter.tsx | 1 + .../components/thread-panel-header.tsx | 6 + .../hooks/use-thread-panel-filters.ts | 1 + .../components/thread-view/_index.scss | 10 +- .../components/assignees-widget/_index.scss | 17 + .../components/assignees-widget/index.tsx | 106 ++ .../quick-assign-popover/_index.scss | 137 +++ .../quick-assign-popover/index.tsx | 305 ++++++ .../share-modal-extensions/_index.scss | 198 ++++ .../access-role-dropdown.tsx | 99 ++ .../access-users-list.tsx | 94 ++ .../assigned-users-section.tsx | 62 ++ .../share-modal-extensions/index.ts | 4 + .../invitation-user-selector.tsx | 90 ++ .../share-member-item.tsx | 81 ++ .../share-modal-extensions/share-modal.tsx | 543 +++++++++++ .../thread-accesses-widget/index.tsx | 443 +++++++-- .../components/thread-action-bar/_index.scss | 12 + .../components/thread-action-bar/index.tsx | 19 +- .../components/thread-event-input/_index.scss | 11 + .../components/thread-event-input/index.tsx | 27 +- .../components/thread-event/_index.scss | 66 ++ .../thread-event/assignment-message.test.ts | 153 +++ .../thread-event/assignment-message.ts | 136 +++ .../thread-event/group-system-events.test.ts | 176 ++++ .../components/thread-event/index.tsx | 196 +++- .../layouts/components/thread-view/index.tsx | 34 +- .../features/message/use-assigned-users.ts | 49 + .../features/message/use-thread-assignment.ts | 163 ++++ .../src/features/providers/mailbox.tsx | 9 + .../assignees-avatar-group/_index.scss | 54 ++ .../assignees-avatar-group/index.test.tsx | 96 ++ .../assignees-avatar-group/index.tsx | 59 ++ .../src/features/utils/date-helper.ts | 30 + .../src/hooks/use-is-shared-context.ts | 16 + src/frontend/src/styles/main.scss | 4 + 101 files changed, 8823 insertions(+), 669 deletions(-) create mode 100644 src/backend/core/migrations/0026_userevent_usrevt_user_thread_assign_uniq.py create mode 100644 src/backend/core/services/thread_events.py create mode 100644 src/backend/core/tests/api/test_thread_filter_assignment.py create mode 100644 src/backend/core/tests/models/test_thread_event.py create mode 100644 src/backend/core/tests/services/test_thread_events.py delete mode 100644 src/frontend/src/features/api/gen/models/paginated_thread_access_list.ts delete mode 100644 src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of.ts delete mode 100644 src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of_mentions_item.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_assignees_data.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_assignees_data_request.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_data.ts delete mode 100644 src/frontend/src/features/api/gen/models/thread_event_data_one_of.ts delete mode 100644 src/frontend/src/features/api/gen/models/thread_event_data_one_of_mentions_item.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_data_request.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_im_data.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_im_data_request.ts delete mode 100644 src/frontend/src/features/api/gen/models/thread_event_request_data_one_of.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_user.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_event_user_request.ts create mode 100644 src/frontend/src/features/api/gen/models/thread_mentionable_user.ts rename src/frontend/src/features/api/gen/models/{thread_event_request_data_one_of_mentions_item.ts => thread_mentionable_user_custom_attributes.ts} (53%) create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/_index.scss create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/index.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/_index.scss create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/index.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/_index.scss create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-role-dropdown.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-users-list.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/assigned-users-section.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/index.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/invitation-user-selector.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-member-item.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.test.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-event/assignment-message.ts create mode 100644 src/frontend/src/features/layouts/components/thread-view/components/thread-event/group-system-events.test.ts create mode 100644 src/frontend/src/features/message/use-assigned-users.ts create mode 100644 src/frontend/src/features/message/use-thread-assignment.ts create mode 100644 src/frontend/src/features/ui/components/assignees-avatar-group/_index.scss create mode 100644 src/frontend/src/features/ui/components/assignees-avatar-group/index.test.tsx create mode 100644 src/frontend/src/features/ui/components/assignees-avatar-group/index.tsx create mode 100644 src/frontend/src/hooks/use-is-shared-context.ts diff --git a/docs/permissions.md b/docs/permissions.md index b685b329c..69de71ab5 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -1,3 +1,4 @@ + # Permissions & Data Model ## Core Data Model @@ -17,11 +18,14 @@ User │ │ │ ├── blob → Blob (raw MIME) │ │ │ ├── draft_blob → Blob (JSON draft content) │ │ │ └── attachments → Attachment → Blob (only for drafts) +│ │ ├── events → ThreadEvent (im / assign / unassign) +│ │ │ └── user_events → UserEvent (mention / assign) │ │ ├── accesses → ThreadAccess (multiple mailboxes) │ │ └── labels → Label (M2M) │ ├── contacts → Contact │ ├── labels → Label │ └── blobs → Blob +├── user_events → UserEvent (per-user notifications, thread-scoped) └── MailDomainAccess (role: ADMIN) └── MailDomain └── mailboxes → Mailbox (via domain FK) @@ -37,6 +41,8 @@ User | **Thread** | Message thread | `subject`, denormalized flags (`has_trashed`, `is_spam`, etc.) | | **ThreadAccess** | Mailbox→Thread permission | `thread`, `mailbox`, `role` (unique together) | | **Message** | Email message | `thread`, `sender`, `parent`, flags (`is_draft`, `is_trashed`, etc.) | +| **ThreadEvent** | Timeline entry on a thread (comment, assign, unassign) | `thread`, `type`, `author`, `data` (JSON, schema-validated per type) | +| **UserEvent** | Per-user notification derived from a ThreadEvent | `user`, `thread`, `thread_event`, `type`, `read_at` | | **Contact** | Email address entity | `email`, `mailbox`, `name` | | **Label** | Folder/tag (hierarchical) | `name`, `slug`, `mailbox`, `threads` (M2M) | @@ -58,16 +64,57 @@ Role groups defined in `enums.py`: ### ThreadAccessRoleChoices (Mailbox access to Thread) ```python -VIEWER = 1 # Read-only: view thread messages -EDITOR = 2 # Edit: create replies, flag messages, manage thread sharing +VIEWER = 1 # Read-only: view thread messages and events +EDITOR = 2 # Edit: create replies, flag messages, manage thread sharing, assign ``` Role group: - `THREAD_ROLES_CAN_EDIT = [EDITOR]` -### Key Design Principles +### Event Types + +```python +# ThreadEvent.type (stored on the thread timeline) +IM = "im" # Internal comment, may embed mentions in data +ASSIGN = "assign" # User(s) newly assigned to the thread +UNASSIGN = "unassign" # User(s) removed from the thread + +# UserEvent.type (per-user notification, derived from ThreadEvent) +MENTION = "mention" # One per (user, message mention); read_at tracks ack +ASSIGN = "assign" # At most one per (user, thread); source of truth for "assigned" +``` + +`UserEvent` is **not** mailbox-scoped: a user reachable through several mailboxes sees the same notification everywhere. + +## Permission Classes + +Defined in `core/api/permissions.py`. The table below lists the main ones and the rule they enforce. + +| Class | Rule | +|-------|------| +| `IsAuthenticated` | Baseline — user is logged in. | +| `IsAllowedToAccess` | Read access to a Mailbox/Thread/Message/ThreadEvent via any `MailboxAccess` → `ThreadAccess` path. | +| `HasThreadEditAccess` | Full edit rights: `ThreadAccess.role == EDITOR` **AND** `MailboxAccess.role ∈ MAILBOX_ROLES_CAN_EDIT` on the same mailbox. | +| `HasThreadCommentAccess` | Allowed to author internal comments: any `ThreadAccess` (viewer or editor) on a mailbox where the user has `MAILBOX_ROLES_CAN_EDIT`. | +| `HasThreadEventWriteAccess` | Type-aware: `im` events follow the comment rule; every other `ThreadEvent` type requires full edit rights. Update/destroy is author-only. | +| `IsAllowedToCreateMessage` | User must have `MAILBOX_ROLES_CAN_EDIT` on the sender mailbox (plus EDITOR `ThreadAccess` when replying). | +| `IsAllowedToManageThreadAccess` | Managing a `ThreadAccess` requires full edit rights on the thread. | +| `IsMailboxAdmin` / `IsMailDomainAdmin` | Admin paths for mailbox and maildomain management. | +| `HasChannelScope` | Scope check for Channel-authenticated calls; `CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY` further requires `scope_level=global`. | + +Shared ORM helpers (`core/models.py`): +- `ThreadAccess.objects.editable_by(user, mailbox_id=None)` — rows matching the full-edit-rights rule. +- `ThreadAccess.objects.editor_user_ids(thread_id, user_ids=None)` — user ids with full edit rights on a thread. + +## Key Design Principles -1. **Two-level permission model**: User→Mailbox (MailboxAccess) and Mailbox→Thread (ThreadAccess) are independent. -2. **ThreadAccess is per-mailbox**: Each mailbox has its own access level to a thread, enabling selective sharing. -3. **Flags are shared state**: Message flags (`is_trashed`, `is_spam`, `is_unread`, etc.) are stored on the Message model directly, not per-user. Modifying them requires EDITOR ThreadAccess. -4. **Thread stats are denormalized**: Thread has boolean fields (`has_trashed`, `is_spam`, etc.) updated by `thread.update_stats()` after message flag changes. +1. **Two-level permission model.** User→Mailbox (`MailboxAccess`) and Mailbox→Thread (`ThreadAccess`) are independent and composed for every access check. Edit-level actions always verify **both** sides. +2. **ThreadAccess is per-mailbox.** Each mailbox has its own role on a given thread, enabling selective sharing and per-mailbox `read_at` / `starred_at` state. +3. **Flags are shared state.** Message flags (`is_trashed`, `is_spam`, `is_unread`, etc.) live on the Message and mutate the thread for everyone — they require EDITOR `ThreadAccess`. +4. **Thread stats are denormalized.** Thread has boolean fields (`has_trashed`, `is_spam`, …) updated by `thread.update_stats()` after message flag changes. Mention/assignment stats (`has_mention`, `has_unread_mention`, `has_assigned_to_me`, `has_unassigned`) are **not** stored: they are computed per request via `Exists(UserEvent...)` annotations in `ThreadViewSet`. +5. **Comments relax the thread role.** Posting or editing an `im` `ThreadEvent` only requires VIEWER `ThreadAccess` + mailbox edit rights. Assign/unassign and any other event type keep the stricter full-edit-rights policy. +6. **Event mutations are author-only.** Update and destroy of a `ThreadEvent` are refused for non-authors, regardless of role. A configurable window (`settings.MAX_THREAD_EVENT_EDIT_DELAY`) can close the edit/delete path entirely after creation. +7. **Assignment is derived from the event log.** `UserEvent(type=ASSIGN)` is the source of truth for "who is assigned"; there is no denormalized field on Thread. A partial `UniqueConstraint` enforces at most one active ASSIGN per `(user, thread)` and absorbs races between concurrent ASSIGN requests. +8. **Undo window for assignments.** An UNASSIGN within `UNDO_WINDOW_SECONDS` (120s) of the matching ASSIGN, by the same author, is absorbed: the original ASSIGN `ThreadEvent` is trimmed or deleted, the `UserEvent ASSIGN` is removed, and no UNASSIGN event is emitted. +9. **Access changes cascade to assignments.** Downgrading or removing a `ThreadAccess` / `MailboxAccess` triggers `cleanup_invalid_assignments`, which emits a single system `ThreadEvent(type=UNASSIGN, author=None)` for any assignee who lost full edit rights (re-evaluated across all their mailboxes). +10. **Mentions survive edits idempotently.** Editing an `im` event diffs the mentions payload and reconciles `UserEvent(MENTION)` rows; unchanged mentions keep their `read_at`, removed ones disappear from the user's "Mentioned" view, new ones are created. diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 6e8c4bf2b..01ff5c2d1 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -21,12 +21,13 @@ from core.api.utils import get_file_key from core.api.viewsets.task import register_task_owner from core.mda.outbound_tasks import retry_messages_task +from core.services import thread_events as thread_events_service from core.services.dns.provisioning import provision_domain_dns from core.services.exporter.tasks import export_mailbox_task from core.services.importer.service import ImportService from core.services.throttle import get_throttle_status -from . import models +from . import enums, models from .enums import MessageDeliveryStatusChoices from .forms import IMAPImportForm, MessageImportForm @@ -388,6 +389,13 @@ class MailboxAdmin(admin.ModelAdmin): change_form_template = "admin/core/mailbox/change_form.html" readonly_fields = ("throttle_status_display",) + def save_formset(self, request, form, formset, change): + """Route MailboxAccess inline edits through the cleanup service.""" + if formset.model is models.MailboxAccess: + _cleanup_mailbox_access_formset(formset) + return + super().save_formset(request, form, formset, change) + def get_queryset(self, request): """Optimize queryset with select_related for better performance""" return ( @@ -584,15 +592,129 @@ def regenerate_api_key_view(self, request, object_id): @admin.register(models.MailboxAccess) class MailboxAccessAdmin(admin.ModelAdmin): - """Admin class for the MailboxAccess model""" + """Admin class for the MailboxAccess model. + + Routes all writes through ``thread_events_service`` so that role + downgrades and deletions clean up assignments/mentions exactly the + same way the API viewsets do. Without these overrides, an operator + editing a row directly from the admin would leave stale ``UserEvent`` + rows behind. + """ list_display = ("id", "mailbox", "user", "role") search_fields = ("mailbox__local_part", "mailbox__domain__name", "user__email") autocomplete_fields = ("mailbox", "user") + def save_model(self, request, obj, form, change): + """Detect role downgrades or principal changes and trigger cleanup. + + When ``mailbox`` or ``user`` is reassigned, the row the previous + principal had is gone after save, so we must revoke against the + pre-save snapshot rather than the new ``obj``. + """ + previous = None + if change and obj.pk: + previous = models.MailboxAccess.objects.filter(pk=obj.pk).first() + super().save_model(request, obj, form, change) + if previous is None: + return + if (previous.mailbox_id, previous.user_id) != ( + obj.mailbox_id, + obj.user_id, + ): + thread_events_service.revoke_mailbox_access(mailbox_access=previous) + return + was_editor = previous.role in enums.MAILBOX_ROLES_CAN_EDIT + is_editor = obj.role in enums.MAILBOX_ROLES_CAN_EDIT + if was_editor and not is_editor: + thread_events_service.downgrade_mailbox_access(mailbox_access=obj) + + def delete_model(self, request, obj): + """Delete the row, then cleanup using the in-memory instance. + + Order matters: ``revoke_mailbox_access`` re-runs the editor / + viewer-rights queries to decide who lost their effective access, + and those queries must observe a state where ``obj`` is gone. + """ + super().delete_model(request, obj) + thread_events_service.revoke_mailbox_access(mailbox_access=obj) + + def delete_queryset(self, request, queryset): + """Bulk-delete then cleanup per row. + + ``queryset`` is consumed before ``super().delete_queryset()`` + deletes the rows, so each ``MailboxAccess`` is still readable in + memory afterwards for the cleanup pass. + """ + rows = list(queryset) + super().delete_queryset(request, queryset) + for obj in rows: + thread_events_service.revoke_mailbox_access(mailbox_access=obj) + + +@admin.register(models.ThreadAccess) +class ThreadAccessAdmin(admin.ModelAdmin): + """Admin class for the ThreadAccess model. + + Registered explicitly so that direct admin writes flow through the + cleanup service. ``ThreadAccess`` is also surfaced as an inline on + ``ThreadAdmin``; inline edits go through ``ThreadAdmin.save_formset`` + rather than this class. + """ + + list_display = ("id", "thread", "mailbox", "role") + search_fields = ( + "thread__subject", + "mailbox__local_part", + "mailbox__domain__name", + ) + autocomplete_fields = ("thread", "mailbox") + + def save_model(self, request, obj, form, change): + """Detect EDITOR → other transitions or principal changes and cleanup. + + When ``thread`` or ``mailbox`` is reassigned, the row the previous + principal had is gone after save, so we must revoke against the + pre-save snapshot rather than the new ``obj``. + """ + previous = None + if change and obj.pk: + previous = models.ThreadAccess.objects.filter(pk=obj.pk).first() + super().save_model(request, obj, form, change) + if previous is None: + return + if (previous.thread_id, previous.mailbox_id) != ( + obj.thread_id, + obj.mailbox_id, + ): + thread_events_service.revoke_thread_access(thread_access=previous) + return + if ( + previous.role == enums.ThreadAccessRoleChoices.EDITOR + and obj.role != enums.ThreadAccessRoleChoices.EDITOR + ): + thread_events_service.downgrade_thread_access(thread_access=obj) + + def delete_model(self, request, obj): + """Delete the row, then cleanup using the in-memory instance.""" + super().delete_model(request, obj) + thread_events_service.revoke_thread_access(thread_access=obj) + + def delete_queryset(self, request, queryset): + """Bulk-delete then cleanup per row.""" + rows = list(queryset) + super().delete_queryset(request, queryset) + for obj in rows: + thread_events_service.revoke_thread_access(thread_access=obj) + class ThreadAccessInline(admin.TabularInline): - """Inline class for the ThreadAccess model""" + """Inline class for the ThreadAccess model. + + Inline writes are routed through ``ThreadAdmin.save_formset`` so that + role downgrades and deletions trigger the same assignment cleanup as + the standalone ``ThreadAccessAdmin`` and the API viewset. + """ model = models.ThreadAccess autocomplete_fields = ("mailbox",) @@ -600,14 +722,32 @@ class ThreadAccessInline(admin.TabularInline): class ThreadEventInline(admin.TabularInline): - """Inline class for the ThreadEvent model""" + """Inline class for the ThreadEvent model. + + Read-only on purpose: ASSIGN/UNASSIGN/IM events have invariants tied + to ``UserEvent`` rows that the service layer enforces. Authoring them + by hand from the admin would leave the user-facing notifications + inconsistent with the timeline. Operators inspecting a thread can + still see every event and its data — the lock is on creation/edit + only. + """ model = models.ThreadEvent - autocomplete_fields = ("author", "channel") raw_id_fields = ("message",) - readonly_fields = ("created_at",) + readonly_fields = ( + "type", + "author", + "channel", + "message", + "data", + "created_at", + ) + can_delete = False extra = 0 + def has_add_permission(self, request, obj=None): + return False + class UserEventInline(admin.TabularInline): """Inline class for the UserEvent model. @@ -634,11 +774,110 @@ def has_add_permission(self, request, obj=None): return False +def _cleanup_thread_access_formset(formset): + """Run the assignment cleanup service for a ThreadAccess inline formset. + + Captures pre-mutation state (full snapshots, instances scheduled for + deletion) before ``formset.save()`` actually mutates the database, + then runs the cleanup service afterwards so the editor-rights + queries observe the post-mutation state. Snapshots the full row + (not just role) so reassigning ``thread`` or ``mailbox`` in place + revokes the old grant rather than leaving it behind. + """ + previous_snapshots = {} + instances_to_revoke = [] + tracked_fields = ("role", "thread", "mailbox") + for sub_form in formset.forms: + if not sub_form.instance.pk: + continue + if sub_form.cleaned_data.get("DELETE"): + instances_to_revoke.append(sub_form.instance) + continue + if any(field in (sub_form.changed_data or []) for field in tracked_fields): + previous_snapshots[sub_form.instance.pk] = ( + models.ThreadAccess.objects.filter(pk=sub_form.instance.pk).first() + ) + + formset.save() + + for instance in instances_to_revoke: + thread_events_service.revoke_thread_access(thread_access=instance) + + for sub_form in formset.forms: + if not sub_form.instance.pk or sub_form.cleaned_data.get("DELETE"): + continue + previous = previous_snapshots.get(sub_form.instance.pk) + if previous is None: + continue + new = sub_form.instance + if (previous.thread_id, previous.mailbox_id) != ( + new.thread_id, + new.mailbox_id, + ): + thread_events_service.revoke_thread_access(thread_access=previous) + continue + if ( + previous.role == enums.ThreadAccessRoleChoices.EDITOR + and new.role != enums.ThreadAccessRoleChoices.EDITOR + ): + thread_events_service.downgrade_thread_access(thread_access=new) + + +def _cleanup_mailbox_access_formset(formset): + """Run the assignment cleanup service for a MailboxAccess inline formset. + + Snapshots the full row (not just role) so that reassigning ``mailbox`` + or ``user`` in place revokes the old principal's grant rather than + leaving stale assignment/user-event state behind. + """ + previous_snapshots = {} + instances_to_revoke = [] + tracked_fields = ("role", "mailbox", "user") + for sub_form in formset.forms: + if not sub_form.instance.pk: + continue + if sub_form.cleaned_data.get("DELETE"): + instances_to_revoke.append(sub_form.instance) + continue + if any(field in (sub_form.changed_data or []) for field in tracked_fields): + previous_snapshots[sub_form.instance.pk] = ( + models.MailboxAccess.objects.filter(pk=sub_form.instance.pk).first() + ) + + formset.save() + + for instance in instances_to_revoke: + thread_events_service.revoke_mailbox_access(mailbox_access=instance) + + for sub_form in formset.forms: + if not sub_form.instance.pk or sub_form.cleaned_data.get("DELETE"): + continue + previous = previous_snapshots.get(sub_form.instance.pk) + if previous is None: + continue + new = sub_form.instance + if (previous.mailbox_id, previous.user_id) != (new.mailbox_id, new.user_id): + thread_events_service.revoke_mailbox_access(mailbox_access=previous) + continue + was_editor = previous.role in enums.MAILBOX_ROLES_CAN_EDIT + is_editor = new.role in enums.MAILBOX_ROLES_CAN_EDIT + if was_editor and not is_editor: + thread_events_service.downgrade_mailbox_access(mailbox_access=new) + + @admin.register(models.Thread) class ThreadAdmin(admin.ModelAdmin): """Admin class for the Thread model""" inlines = [ThreadAccessInline, ThreadEventInline, UserEventInline] + + def save_formset(self, request, form, formset, change): + """Route ThreadAccess inline edits through the cleanup service.""" + if formset.model is models.ThreadAccess: + _cleanup_thread_access_formset(formset) + return + super().save_formset(request, form, formset, change) + list_display = ( "id", "subject", diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index e7f17dd44..c0469d419 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -4828,6 +4828,14 @@ }, "description": "Filter threads that have archived (1=true, 0=false)." }, + { + "in": "query", + "name": "has_assigned_to_me", + "schema": { + "type": "integer" + }, + "description": "Filter threads assigned to the current user (1=true, 0=false)." + }, { "in": "query", "name": "has_attachments", @@ -4892,6 +4900,14 @@ }, "description": "Filter threads that have trashed messages (1=true, 0=false)." }, + { + "in": "query", + "name": "has_unassigned", + "schema": { + "type": "integer" + }, + "description": "Filter threads with no active assignment from any user (1=true, 0=false)." + }, { "in": "query", "name": "has_unread", @@ -5236,15 +5252,6 @@ }, "description": "Filter thread accesses by mailbox ID." }, - { - "name": "page", - "required": false, - "in": "query", - "description": "A page number within the paginated result set.", - "schema": { - "type": "integer" - } - }, { "in": "path", "name": "thread_id", @@ -5268,7 +5275,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PaginatedThreadAccessList" + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadAccess" + } } } }, @@ -5563,7 +5573,7 @@ }, "post": { "operationId": "threads_events_create", - "description": "ViewSet for ThreadEvent model.", + "description": "Create a ThreadEvent.\n\nFor ASSIGN/UNASSIGN, delegates to the service layer which owns the\nidempotence rules, edit-rights validation and the undo window.\nFor IM, persists the event via the serializer and then re-syncs\nMENTION rows.\n\nReturns 204 when the service decides nothing was new (every\nassignee already assigned, full UNASSIGN absorbed by undo, …).", "parameters": [ { "in": "path", @@ -5889,7 +5899,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/UserWithoutAbilities" + "$ref": "#/components/schemas/ThreadMentionableUser" } } } @@ -5912,6 +5922,14 @@ }, "description": "Filter threads that are archived (1=true, 0=false)." }, + { + "in": "query", + "name": "has_assigned_to_me", + "schema": { + "type": "integer" + }, + "description": "Filter threads assigned to the current user (1=true, 0=false)." + }, { "in": "query", "name": "has_attachments", @@ -5968,6 +5986,14 @@ }, "description": "Filter threads that are trashed (1=true, 0=false)." }, + { + "in": "query", + "name": "has_unassigned", + "schema": { + "type": "integer" + }, + "description": "Filter threads with no active assignment from any user (1=true, 0=false)." + }, { "in": "query", "name": "has_unread_mention", @@ -6009,13 +6035,15 @@ "enum": [ "all", "all_unread", + "has_assigned_to_me", "has_delivery_failed", "has_delivery_pending", "has_mention", + "has_unassigned", "has_unread_mention" ] }, - "description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'", + "description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention, has_assigned_to_me, has_unassigned.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'", "required": true, "explode": false, "style": "form" @@ -7200,6 +7228,11 @@ "readOnly": true, "description": "Whether this mailbox identifies a person (i.e. is not an alias or a group)" }, + "is_shared": { + "type": "boolean", + "description": "Return True if the mailbox is shared (non-identity or has more than one access).\n\nDrives mailbox-level UI gating for collaboration features (assignment\nsub-folders, mention folder) that have no purpose in a mono-user\nidentity mailbox.", + "readOnly": true + }, "role": { "allOf": [ { @@ -7302,6 +7335,7 @@ "email", "id", "is_identity", + "is_shared", "role" ] }, @@ -7683,11 +7717,17 @@ "name": { "type": "string", "readOnly": true + }, + "is_identity": { + "type": "boolean", + "readOnly": true, + "description": "Whether this mailbox identifies a person (i.e. is not an alias or a group)" } }, "required": [ "email", "id", + "is_identity", "name" ] }, @@ -8361,37 +8401,6 @@ } } }, - "PaginatedThreadAccessList": { - "type": "object", - "required": [ - "count", - "results" - ], - "properties": { - "count": { - "type": "integer", - "example": 123 - }, - "next": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=4" - }, - "previous": { - "type": "string", - "nullable": true, - "format": "uri", - "example": "http://api.example.org/accounts/?page=2" - }, - "results": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ThreadAccess" - } - } - } - }, "PaginatedThreadList": { "type": "object", "required": [ @@ -8615,40 +8624,7 @@ "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 - } - ] + "$ref": "#/components/schemas/ThreadEventDataRequest" } } }, @@ -9062,6 +9038,13 @@ "type": "boolean" }, "readOnly": true + }, + "assigned_users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEventUser" + }, + "readOnly": true } }, "required": [ @@ -9069,6 +9052,7 @@ "accesses", "active_messaged_at", "archived_messaged_at", + "assigned_users", "draft_messaged_at", "events_count", "has_active", @@ -9135,6 +9119,13 @@ "readOnly": true, "title": "Updated on", "description": "date and time at which a record was last updated" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserWithoutAbilities" + }, + "readOnly": true } }, "required": [ @@ -9143,7 +9134,8 @@ "mailbox", "role", "thread", - "updated_at" + "updated_at", + "users" ] }, "ThreadAccessDetail": { @@ -9260,40 +9252,7 @@ "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 - } - ] + "$ref": "#/components/schemas/ThreadEventData" }, "has_unread_mention": { "type": "boolean", @@ -9331,6 +9290,93 @@ "updated_at" ] }, + "ThreadEventAssigneesData": { + "type": "object", + "description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events.\n\nBoth event types share the exact same payload shape, so a single serializer\n(and thus a single generated TypeScript type) covers them.\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEventUser" + } + } + }, + "required": [ + "assignees" + ] + }, + "ThreadEventAssigneesDataRequest": { + "type": "object", + "description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events.\n\nBoth event types share the exact same payload shape, so a single serializer\n(and thus a single generated TypeScript type) covers them.\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "assignees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEventUserRequest" + } + } + }, + "required": [ + "assignees" + ] + }, + "ThreadEventData": { + "oneOf": [ + { + "$ref": "#/components/schemas/ThreadEventIMData" + }, + { + "$ref": "#/components/schemas/ThreadEventAssigneesData" + } + ] + }, + "ThreadEventDataRequest": { + "oneOf": [ + { + "$ref": "#/components/schemas/ThreadEventIMDataRequest" + }, + { + "$ref": "#/components/schemas/ThreadEventAssigneesDataRequest" + } + ] + }, + "ThreadEventIMData": { + "type": "object", + "description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "content": { + "type": "string" + }, + "mentions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEventUser" + } + } + }, + "required": [ + "content" + ] + }, + "ThreadEventIMDataRequest": { + "type": "object", + "description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "content": { + "type": "string", + "minLength": 1 + }, + "mentions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadEventUserRequest" + } + } + }, + "required": [ + "content" + ] + }, "ThreadEventRequest": { "type": "object", "description": "Serialize thread event information.", @@ -9345,40 +9391,7 @@ "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 - } - ] + "$ref": "#/components/schemas/ThreadEventDataRequest" } }, "required": [ @@ -9388,10 +9401,47 @@ }, "ThreadEventTypeEnum": { "enum": [ - "im" + "im", + "assign", + "unassign" ], "type": "string", - "description": "* `im` - Instant message" + "description": "* `im` - Instant message\n* `assign` - Assign\n* `unassign` - Unassign" + }, + "ThreadEventUser": { + "type": "object", + "description": "OpenAPI-only serializer: describes a single user inside\nan ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events)\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ] + }, + "ThreadEventUserRequest": { + "type": "object", + "description": "OpenAPI-only serializer: describes a single user inside\nan ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events)\n\nNot used for runtime validation (handled by\n``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``);\nexists solely to produce a named component in the OpenAPI schema consumed\nby the generated frontend client.", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "name" + ] }, "ThreadLabel": { "type": "object", @@ -9441,6 +9491,47 @@ "slug" ] }, + "ThreadMentionableUser": { + "type": "object", + "description": "User listed in a thread's mention suggestions, with comment capability flag.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "email": { + "type": "string", + "format": "email", + "readOnly": true, + "nullable": true, + "title": "Identity email address" + }, + "full_name": { + "type": "string", + "readOnly": true, + "nullable": true + }, + "custom_attributes": { + "type": "object", + "additionalProperties": {}, + "description": "Get custom attributes for the instance.", + "readOnly": true + }, + "can_post_comments": { + "type": "boolean", + "readOnly": true + } + }, + "required": [ + "can_post_comments", + "custom_attributes", + "email", + "full_name", + "id" + ] + }, "ThreadSplitRequestRequest": { "type": "object", "properties": { diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index e3c4979cf..4dcb4f670 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -561,6 +561,20 @@ def _user_can_comment_on_thread(user, thread_id): ).exists() +def _is_thread_event_mutation_by_non_author(request, view, obj): + """True when a ThreadEvent update/destroy is attempted by a non-author. + + Shared owner-only invariant enforced by every permission class that + guards ThreadEvent writes — factored out so the rule is expressed once + and the two classes below cannot drift. + """ + if not isinstance(obj, models.ThreadEvent): + return False + if view.action not in ("update", "partial_update", "destroy"): + return False + return obj.author_id != request.user.id + + class HasThreadCommentAccess(IsAuthenticated): """Allows users who can author internal comments on the thread. @@ -625,10 +639,7 @@ def has_object_permission(self, request, view, obj): if not isinstance(obj, models.ThreadEvent): return False - if ( - view.action in ("update", "partial_update", "destroy") - and obj.author_id != request.user.id - ): + if _is_thread_event_mutation_by_non_author(request, view, obj): return False if obj.type == enums.ThreadEventTypeChoices.IM: @@ -679,16 +690,10 @@ def has_object_permission(self, request, view, obj): ``ThreadEvent``, also enforces that only the event author can perform update or destroy actions. """ - if isinstance(obj, models.ThreadEvent): - if ( - view.action in ("update", "partial_update", "destroy") - and obj.author_id != request.user.id - ): - return False - thread = obj.thread - else: - thread = obj + if _is_thread_event_mutation_by_non_author(request, view, obj): + return False + thread = obj.thread if isinstance(obj, models.ThreadEvent) else obj return thread.get_abilities(request.user)[enums.ThreadAbilities.CAN_EDIT] diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 028cbcaab..14241a73e 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -10,7 +10,7 @@ from django.db import transaction from django.db.models import Count, Q -from drf_spectacular.utils import extend_schema_field +from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -40,18 +40,96 @@ 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. +class ThreadEventUserSerializer(serializers.Serializer): + """ + OpenAPI-only serializer: describes a single user inside + an ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events) + + Not used for runtime validation (handled by + ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); + exists solely to produce a named component in the OpenAPI schema consumed + by the generated frontend client. + """ + + id = serializers.UUIDField() + name = serializers.CharField() + + def create(self, validated_data): + """Do not allow creating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support create method") + + def update(self, instance, validated_data): + """Do not allow updating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support update method") + + +class ThreadEventIMDataSerializer(serializers.Serializer): + """OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events. + + Not used for runtime validation (handled by + ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); + exists solely to produce a named component in the OpenAPI schema consumed + by the generated frontend client. + """ + + content = serializers.CharField() + mentions = ThreadEventUserSerializer(many=True, required=False) + + def create(self, validated_data): + """Do not allow creating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support create method") - Returns a `oneOf` composition when multiple types are defined. + def update(self, instance, validated_data): + """Do not allow updating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support update method") + + +class ThreadEventAssigneesDataSerializer(serializers.Serializer): + """OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events. + + Both event types share the exact same payload shape, so a single serializer + (and thus a single generated TypeScript type) covers them. + + Not used for runtime validation (handled by + ``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); + exists solely to produce a named component in the OpenAPI schema consumed + by the generated frontend client. """ - schemas = models.ThreadEvent.DATA_SCHEMAS - return {"oneOf": list(schemas.values())} + assignees = ThreadEventUserSerializer(many=True, min_length=1) + + def create(self, validated_data): + """Do not allow creating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support create method") -@extend_schema_field(_build_thread_event_data_schema()) + def update(self, instance, validated_data): + """Do not allow updating instances from this serializer.""" + raise RuntimeError(f"{self.__class__.__name__} does not support update method") + + +@extend_schema_field( + PolymorphicProxySerializer( + component_name="ThreadEventData", + serializers=[ + ThreadEventIMDataSerializer, + ThreadEventAssigneesDataSerializer, + ], + resource_type_field_name=None, + ) +) class ThreadEventDataField(serializers.JSONField): - """JSONField for ThreadEvent.data, OpenAPI-annotated from model DATA_SCHEMAS.""" + """JSONField for ``ThreadEvent.data``. + + Runtime validation is performed by ``ThreadEvent.validate_data()`` against + ``ThreadEvent.DATA_SCHEMAS``, invoked both by ``ThreadEventSerializer`` + (to surface 400 errors at the HTTP boundary) and by + ``ThreadEvent.clean()`` (defence in depth for ORM writes). + The ``@extend_schema_field`` decorator only affects OpenAPI generation: + it emits a named ``oneOf`` over the two ``*DataSerializer`` variants so + orval produces stable, readable TypeScript types + (``ThreadEventImData``, ``ThreadEventAssigneesData``) instead of + positional ``OneOfN`` names. + """ class IntegerChoicesField(serializers.ChoiceField): @@ -227,6 +305,16 @@ class UserWithoutAbilitiesSerializer(UserSerializer): exclude_abilities = True +class ThreadMentionableUserSerializer(UserWithoutAbilitiesSerializer): + """User listed in a thread's mention suggestions, with comment capability flag.""" + + can_post_comments = serializers.BooleanField(read_only=True) + + class Meta(UserWithoutAbilitiesSerializer.Meta): + fields = UserWithoutAbilitiesSerializer.Meta.fields + ["can_post_comments"] + read_only_fields = fields + + class MailboxAvailableSerializer(serializers.ModelSerializer): """Serialize mailboxes.""" @@ -257,6 +345,7 @@ class MailboxSerializer(AbilitiesModelSerializer): count_threads = serializers.SerializerMethodField(read_only=True) count_delivering = serializers.SerializerMethodField(read_only=True) count_unread_mentions = serializers.SerializerMethodField(read_only=True) + is_shared = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Mailbox @@ -264,6 +353,7 @@ class Meta: "id", "email", "is_identity", + "is_shared", "role", "count_unread_threads", "count_threads", @@ -333,6 +423,7 @@ def _get_cached_counts(self, instance): ) else: counts["count_unread_mentions"] = 0 + counts["count_accesses"] = instance.accesses.count() setattr(self, cache_key, counts) return getattr(self, cache_key) @@ -352,6 +443,17 @@ def get_count_unread_mentions(self, instance) -> int: """Return the number of threads with unread mentions for the current user.""" return self._get_cached_counts(instance)["count_unread_mentions"] + def get_is_shared(self, instance) -> bool: + """Return True if the mailbox is shared (non-identity or has more than one access). + + Drives mailbox-level UI gating for collaboration features (assignment + sub-folders, mention folder) that have no purpose in a mono-user + identity mailbox. + """ + return (not instance.is_identity) or self._get_cached_counts(instance)[ + "count_accesses" + ] > 1 + @extend_schema_field( { "type": "object", @@ -379,7 +481,7 @@ class MailboxLightSerializer(serializers.ModelSerializer): class Meta: model = models.Mailbox - fields = ["id", "email", "name"] + fields = ["id", "email", "name", "is_identity"] read_only_fields = fields def get_email(self, instance): @@ -652,6 +754,7 @@ class ThreadSerializer(serializers.ModelSerializer): summary = serializers.CharField(read_only=True) events_count = serializers.IntegerField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) + assigned_users = serializers.SerializerMethodField(read_only=True) @extend_schema_field(serializers.DictField(child=serializers.BooleanField())) def get_abilities(self, instance): @@ -705,25 +808,52 @@ def get_has_starred(self, instance): @extend_schema_field(ThreadAccessDetailSerializer(many=True)) def get_accesses(self, instance): - """Return the accesses for the thread.""" - accesses = instance.accesses.select_related("mailbox", "mailbox__contact") + """Return the accesses for the thread. - return ThreadAccessDetailSerializer(accesses, many=True).data + Uses the ``_accesses_with_mailbox`` prefetch cache when present; + the fallback select_related mirrors the prefetch queryset so + ``MailboxLightSerializer`` never lazy-loads the mail domain. + """ + cached = getattr(instance, "_accesses_with_mailbox", None) + if cached is None: + cached = instance.accesses.select_related( + "mailbox", "mailbox__domain", "mailbox__contact" + ) + return ThreadAccessDetailSerializer(cached, many=True).data def get_messages(self, instance): - """Return the messages in the thread.""" - # Consider performance for large threads; pagination might be needed here? - return [str(message.id) for message in instance.messages.order_by("created_at")] + """Return the IDs of the thread messages, chronologically ordered.""" + cached = getattr(instance, "_ordered_messages", None) + if cached is None: + cached = instance.messages.order_by("created_at") + return [str(message.id) for message in cached] @extend_schema_field( IntegerChoicesField(choices_class=models.ThreadAccessRoleChoices) ) def get_user_role(self, instance): - """Get current user's role for this thread, scoped to the context mailbox.""" + """Get current user's role for this thread, scoped to the context mailbox. + + Walks the ``_accesses_with_mailbox`` prefetch cache when available + to avoid a per-thread SQL round-trip; falls back to a direct query + for code paths that build threads outside the annotated queryset + (e.g. the ``split`` action). + """ mailbox_id = self.context.get("mailbox_id") if not mailbox_id: return None + cached = getattr(instance, "_accesses_with_mailbox", None) + if cached is not None: + mailbox_id_str = str(mailbox_id) + access = next( + (a for a in cached if str(a.mailbox_id) == mailbox_id_str), + None, + ) + if access is None: + return None + return models.ThreadAccessRoleChoices(access.role).label + try: role_value = instance.accesses.get(mailbox_id=mailbox_id).role return models.ThreadAccessRoleChoices(role_value).label @@ -732,13 +862,46 @@ def get_user_role(self, instance): @extend_schema_field(ThreadLabelSerializer(many=True)) def get_labels(self, instance): - """Get labels for the thread, scoped to the context mailbox.""" + """Get labels for the thread, scoped to the context mailbox. + + Consumes the ``_scoped_labels`` prefetch cache when the viewset has + populated it (requires ``mailbox_id``); otherwise falls back to a + direct query, which keeps the ``split`` action and single-thread + retrieves working. + """ mailbox_id = self.context.get("mailbox_id") if not mailbox_id: return [] - labels = instance.labels.filter(mailbox_id=mailbox_id) - return ThreadLabelSerializer(labels, many=True).data + cached = getattr(instance, "_scoped_labels", None) + if cached is None: + cached = instance.labels.filter(mailbox_id=mailbox_id) + return ThreadLabelSerializer(cached, many=True).data + + @extend_schema_field(ThreadEventUserSerializer(many=True)) + def get_assigned_users(self, instance): + """Return the users currently assigned to this thread. + + ``UserEvent(type=ASSIGN)`` is the denormalized per-user source of truth + for active assignments (rows are created on assign and deleted on + unassign via ``delete_assign_user_events``), so the thread list can + expose assignees without having to replay the ThreadEvent history. + + Uses the ``_assigned_user_events`` prefetch cache when present to + avoid N+1 on list views; falls back to a direct query for code + paths that build threads outside the annotated queryset. + """ + cached = getattr(instance, "_assigned_user_events", None) + if cached is None: + cached = list( + instance.user_events.filter(type=enums.UserEventTypeChoices.ASSIGN) + .select_related("user") + .order_by("created_at") + ) + return [ + {"id": str(event.user.id), "name": event.user.full_name or ""} + for event in cached + ] class Meta: model = models.Thread @@ -775,6 +938,7 @@ class Meta: "summary", "events_count", "abilities", + "assigned_users", ] read_only_fields = fields # Mark all as read-only for safety @@ -1017,13 +1181,40 @@ class ThreadAccessSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer) """Serialize thread access information.""" role = IntegerChoicesField(choices_class=models.ThreadAccessRoleChoices) + users = serializers.SerializerMethodField() class Meta: model = models.ThreadAccess - fields = ["id", "thread", "mailbox", "role", "created_at", "updated_at"] - read_only_fields = ["id", "created_at", "updated_at"] + fields = [ + "id", + "thread", + "mailbox", + "role", + "created_at", + "updated_at", + "users", + ] + read_only_fields = ["id", "created_at", "updated_at", "users"] create_only_fields = ["thread", "mailbox"] + @extend_schema_field(UserWithoutAbilitiesSerializer(many=True)) + def get_users(self, instance): + """Return assignable users of the mailbox. + + Scoped to the thread access because the UI renders the assignable + users per-access. Only roles in MAILBOX_ROLES_CAN_BE_ASSIGNED are + eligible. + """ + accesses = [ + access + for access in instance.mailbox.accesses.all() + if access.role in enums.MAILBOX_ROLES_CAN_BE_ASSIGNED + ] + accesses.sort(key=lambda a: (a.user.full_name or "", a.user.email or "")) + return UserWithoutAbilitiesSerializer( + [a.user for a in accesses], many=True + ).data + class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): """Serialize thread event information.""" @@ -1058,7 +1249,27 @@ class Meta: "created_at", "updated_at", ] - create_only_fields = ["type", "message"] + create_only_fields = ["type", "message", "author"] + + def validate(self, attrs): + """Validate ``data`` against the JSON Schema registered for ``type``. + + Routes through ``ThreadEvent.validate_data`` so the HTTP layer and + ``ThreadEvent.clean()`` share a single source of truth, and so the + client receives a field-level 400 error rather than a generic + ValidationError bubbled up from ``full_clean()`` at save time. + """ + # On partial updates where ``data`` is untouched, skip revalidation: + # the stored ``data`` was already validated at creation. + if "data" not in attrs: + return attrs + + event_type = attrs.get("type") or getattr(self.instance, "type", None) + try: + models.ThreadEvent.validate_data(event_type, attrs["data"]) + except DjangoValidationError as exc: + raise serializers.ValidationError(exc.message_dict) from exc + return attrs @extend_schema_field(serializers.BooleanField()) def get_has_unread_mention(self, obj): diff --git a/src/backend/core/api/viewsets/mailbox_access.py b/src/backend/core/api/viewsets/mailbox_access.py index a6ba23165..d43c89974 100644 --- a/src/backend/core/api/viewsets/mailbox_access.py +++ b/src/backend/core/api/viewsets/mailbox_access.py @@ -1,13 +1,15 @@ """API ViewSet for MailboxAccess model, managed by MailDomain admins or Mailbox admins.""" +from django.db import transaction 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 core import enums, models from core.api import permissions as core_permissions from core.api import serializers as core_serializers +from core.services import thread_events as thread_events_service @extend_schema(tags=["mailbox-accesses"]) @@ -67,3 +69,32 @@ def perform_create(self, serializer): """Set the mailbox from the URL when creating a MailboxAccess.""" mailbox = self.get_mailbox_object() serializer.save(mailbox=mailbox) + + @transaction.atomic + def perform_update(self, serializer): + """Cleanup assignments when the role leaves ``MAILBOX_ROLES_CAN_EDIT``. + + Mentions are not cleaned up here: every MailboxAccess role still + grants read access to the shared threads, so a downgrade alone + never invalidates a mention. + """ + previous_role = serializer.instance.role + mailbox_access = serializer.save() + was_editor = previous_role in enums.MAILBOX_ROLES_CAN_EDIT + is_editor = mailbox_access.role in enums.MAILBOX_ROLES_CAN_EDIT + if was_editor and not is_editor: + thread_events_service.downgrade_mailbox_access( + mailbox_access=mailbox_access + ) + + @transaction.atomic + def perform_destroy(self, instance): + """Delete the access then cleanup assignments and mentions. + + Cleanup runs *after* the row is gone so the editor/viewer-rights + queries inside ``revoke_mailbox_access`` reflect the new state. + ``instance.mailbox_id`` and ``instance.user_id`` are still + readable from the in-memory instance. + """ + super().perform_destroy(instance) + thread_events_service.revoke_mailbox_access(mailbox_access=instance) diff --git a/src/backend/core/api/viewsets/thread.py b/src/backend/core/api/viewsets/thread.py index de3c7ad73..ed1533066 100644 --- a/src/backend/core/api/viewsets/thread.py +++ b/src/backend/core/api/viewsets/thread.py @@ -1,8 +1,9 @@ """API ViewSet for Thread model.""" +# pylint: disable=too-many-lines from django.conf import settings from django.db import transaction -from django.db.models import Count, Exists, OuterRef, Q +from django.db.models import Count, Exists, OuterRef, Prefetch, Q from django.db.models.functions import Coalesce import rest_framework as drf @@ -110,6 +111,8 @@ def get_queryset(self, exclude_spam: bool = True, exclude_trashed: bool = True): "has_delivery_pending": "has_delivery_pending", "has_unread_mention": "_has_unread_mention", "has_mention": "_has_mention", + "has_assigned_to_me": "_has_assigned_to_me", + "has_unassigned": "_has_unassigned", "is_trashed": "is_trashed", "is_spam": "is_spam", } @@ -141,11 +144,12 @@ def get_queryset(self, exclude_spam: bool = True, exclude_trashed: bool = True): @staticmethod def _annotate_thread_permissions(queryset, user, mailbox_id): - """Attach permission/state annotations expected by ThreadSerializer. + """Attach permission/state annotations and prefetches expected by ThreadSerializer. Shared between the regular DB queryset and the OpenSearch fallback so - both code paths always expose the same fields (e.g. ``events_count``), - avoiding silent divergence in the serialized payload. + both code paths always expose the same fields (e.g. ``events_count``, + ``assigned_users``), avoiding silent divergence in the serialized + payload. """ can_edit_qs = models.ThreadAccess.objects.filter( thread=OuterRef("pk"), @@ -174,8 +178,69 @@ def _annotate_thread_permissions(queryset, user, mailbox_id): type=enums.UserEventTypeChoices.MENTION, ) ), + _has_assigned_to_me=Exists( + models.UserEvent.objects.filter( + thread=OuterRef("pk"), + user=user, + type=enums.UserEventTypeChoices.ASSIGN, + ) + ), + _has_unassigned=~Exists( + models.UserEvent.objects.filter( + thread=OuterRef("pk"), + type=enums.UserEventTypeChoices.ASSIGN, + ) + ), events_count=Count("events", distinct=True), _can_edit=Exists(can_edit_qs), + ).prefetch_related( + # Feeds ThreadSerializer.get_assigned_users without N+1. UserEvent + # rows with type=ASSIGN are the source of truth for "currently + # assigned" (created on assign, deleted on unassign). + Prefetch( + "user_events", + queryset=models.UserEvent.objects.filter( + type=enums.UserEventTypeChoices.ASSIGN + ) + .select_related("user") + .order_by("created_at"), + to_attr="_assigned_user_events", + ), + # Feeds ThreadSerializer.get_accesses. ``mailbox__domain`` is + # needed because ``Mailbox.__str__`` (used by MailboxLightSerializer) + # reads ``self.domain.name`` — without it we'd get one query per + # thread to resolve the mail domain. + Prefetch( + "accesses", + queryset=models.ThreadAccess.objects.select_related( + "mailbox", "mailbox__domain", "mailbox__contact" + ), + to_attr="_accesses_with_mailbox", + ), + # Feeds ThreadSerializer.get_messages. The serializer only emits + # message IDs in chronological order, so ordering is baked into + # the prefetch to avoid a re-query per thread. + Prefetch( + "messages", + queryset=models.Message.objects.only( + "id", "thread_id", "created_at" + ).order_by("created_at"), + to_attr="_ordered_messages", + ), + # Feeds ThreadSerializer.get_labels. Labels are scoped to the + # mailbox context when provided; when it is absent the serializer + # short-circuits to ``[]`` and no prefetch is needed. + *( + [ + Prefetch( + "labels", + queryset=models.Label.objects.filter(mailbox_id=mailbox_id), + to_attr="_scoped_labels", + ) + ] + if mailbox_id + else [] + ), ) @staticmethod @@ -279,6 +344,18 @@ def destroy(self, request, *args, **kwargs): location=OpenApiParameter.QUERY, description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).", ), + OpenApiParameter( + name="has_assigned_to_me", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads assigned to the current user (1=true, 0=false).", + ), + OpenApiParameter( + name="has_unassigned", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with no active assignment from any user (1=true, 0=false).", + ), OpenApiParameter( name="stats_fields", type=OpenApiTypes.STR, @@ -287,7 +364,7 @@ def destroy(self, request, *args, **kwargs): description="""Comma-separated list of fields to aggregate. Special values: 'all' (count all threads), 'all_unread' (count all unread threads). Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived, - has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention. + has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention, has_assigned_to_me, has_unassigned. Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread. Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'""", enum=list(enums.THREAD_STATS_FIELDS_MAP.keys()), @@ -352,6 +429,8 @@ def stats(self, request): "has_delivery_pending", "has_unread_mention", "has_mention", + "has_assigned_to_me", + "has_unassigned", "is_spam", "has_messages", } @@ -394,6 +473,8 @@ def stats(self, request): starred_condition = Q(_has_starred=True) unread_mention_condition = Q(_has_unread_mention=True) mention_condition = Q(_has_mention=True) + assigned_to_me_condition = Q(_has_assigned_to_me=True) + unassigned_condition = Q(_has_unassigned=True) aggregations = {} for field in requested_fields: @@ -413,6 +494,10 @@ def stats(self, request): aggregations[agg_key] = Count("pk", filter=unread_mention_condition) elif field == "has_mention": aggregations[agg_key] = Count("pk", filter=mention_condition) + elif field == "has_assigned_to_me": + aggregations[agg_key] = Count("pk", filter=assigned_to_me_condition) + elif field == "has_unassigned": + aggregations[agg_key] = Count("pk", filter=unassigned_condition) elif field.endswith("_unread"): base_field = field[:-7] base_condition = Q(**{base_field: True}) @@ -546,6 +631,18 @@ def stats(self, request): location=OpenApiParameter.QUERY, description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).", ), + OpenApiParameter( + name="has_assigned_to_me", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads assigned to the current user (1=true, 0=false).", + ), + OpenApiParameter( + name="has_unassigned", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with no active assignment from any user (1=true, 0=false).", + ), ], ) def list(self, request, *args, **kwargs): diff --git a/src/backend/core/api/viewsets/thread_access.py b/src/backend/core/api/viewsets/thread_access.py index 60028dce2..9424b7f6c 100644 --- a/src/backend/core/api/viewsets/thread_access.py +++ b/src/backend/core/api/viewsets/thread_access.py @@ -11,6 +11,7 @@ from rest_framework.exceptions import ValidationError from core import enums, models +from core.services import thread_events as thread_events_service from .. import permissions, serializers @@ -44,6 +45,7 @@ class ThreadAccessViewSet( lookup_field = "id" lookup_url_kwarg = "id" queryset = models.ThreadAccess.objects.all() + pagination_class = None def get_queryset(self): """Restrict results to thread accesses for the specified thread.""" @@ -55,6 +57,12 @@ def get_queryset(self): # Filter by thread_id from URL queryset = self.queryset.filter(thread_id=thread_id) + # The list endpoint serializes the `users` field (non-viewer users + # of each mailbox). Prefetching the access/user chain keeps the + # query count constant regardless of the number of accesses. + if self.action == "list": + queryset = queryset.prefetch_related("mailbox__accesses__user") + # Optional mailbox filter mailbox_id = self.request.GET.get("mailbox_id") if mailbox_id: @@ -66,13 +74,33 @@ def create(self, request, *args, **kwargs): request.data["thread"] = self.kwargs.get("thread_id") return super().create(request, *args, **kwargs) + @transaction.atomic + def perform_update(self, serializer): + """Cleanup assignments when an existing access leaves the EDITOR role. + + The role transition is computed against the row stored in DB rather + than the serializer's ``instance`` field because callers may submit + a no-op patch — comparing the persisted state guarantees the + cleanup runs exactly once per real downgrade. + """ + previous_role = serializer.instance.role + thread_access = serializer.save() + if ( + previous_role == enums.ThreadAccessRoleChoices.EDITOR + and thread_access.role != enums.ThreadAccessRoleChoices.EDITOR + ): + thread_events_service.downgrade_thread_access(thread_access=thread_access) + @transaction.atomic def perform_destroy(self, instance): - """Prevent deletion of the last editor access on a thread. + """Prevent deletion of the last editor access; cleanup after delete. Locks every editor row for the thread (including the one being deleted) with FOR UPDATE in id order so concurrent deletions acquire - locks in the same sequence and cannot deadlock. + locks in the same sequence and cannot deadlock. Once the row is + gone, ``thread_events_service.revoke_thread_access`` runs the + assignment/mention cleanup using the still-in-memory ``mailbox`` + and ``thread`` references on the deleted instance. """ if instance.role == enums.ThreadAccessRoleChoices.EDITOR: editor_ids = list( @@ -89,3 +117,4 @@ def perform_destroy(self, instance): "Cannot delete the last editor access of a thread." ) super().perform_destroy(instance) + thread_events_service.revoke_thread_access(thread_access=instance) diff --git a/src/backend/core/api/viewsets/thread_event.py b/src/backend/core/api/viewsets/thread_event.py index df6864ec0..961c07194 100644 --- a/src/backend/core/api/viewsets/thread_event.py +++ b/src/backend/core/api/viewsets/thread_event.py @@ -1,5 +1,6 @@ """API ViewSet for ThreadEvent model.""" +from django.db import transaction from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -7,10 +8,11 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import mixins, status, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response from core import enums, models +from core.services import thread_events as thread_events_service from .. import permissions, serializers @@ -74,22 +76,16 @@ def get_queryset(self): return queryset.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) - def perform_update(self, serializer): - """Reject updates made after the configured edit delay elapsed. - - Past the window, the event (and any UserEvent MENTION records derived - from it) is considered historical and must remain immutable. - """ + """Reject IM updates after the edit delay; re-sync mentions on success.""" if not serializer.instance.is_editable(): raise PermissionDenied( "This event can no longer be edited (edit delay expired)." ) - serializer.save() + thread_event = serializer.save() + # IM is the only editable type; re-sync MENTION rows so an edit + # that changes the mentions list adds/removes notifications. + thread_events_service.sync_im_mentions(thread_event=thread_event) def perform_destroy(self, instance): """Reject deletions made after the configured edit delay elapsed. @@ -126,3 +122,70 @@ def read_mention(self, request, **kwargs): read_at__isnull=True, ).update(read_at=timezone.now()) return Response(status=status.HTTP_204_NO_CONTENT) + + @transaction.atomic + def create(self, request, *args, **kwargs): + """Create a ThreadEvent. + + For ASSIGN/UNASSIGN, delegates to the service layer which owns the + idempotence rules, edit-rights validation and the undo window. + For IM, persists the event via the serializer and then re-syncs + MENTION rows. + + Returns 204 when the service decides nothing was new (every + assignee already assigned, full UNASSIGN absorbed by undo, …). + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + event_type = serializer.validated_data.get("type") + thread = get_object_or_404( + models.Thread.objects.select_for_update(), + id=self.kwargs["thread_id"], + ) + + if event_type == enums.ThreadEventTypeChoices.ASSIGN: + assignees_data = serializer.validated_data["data"]["assignees"] + try: + thread_event = thread_events_service.assign_users( + thread=thread, author=request.user, assignees_data=assignees_data + ) + except ValueError as exc: + raise ValidationError( + "Assignee must have editor access on the thread" + ) from exc + if thread_event is None: + return Response(status=status.HTTP_204_NO_CONTENT) + return self._serialize_created(thread_event) + + if event_type == enums.ThreadEventTypeChoices.UNASSIGN: + assignees_data = serializer.validated_data["data"]["assignees"] + thread_event = thread_events_service.unassign_users( + thread=thread, author=request.user, assignees_data=assignees_data + ) + if thread_event is None: + return Response(status=status.HTTP_204_NO_CONTENT) + return self._serialize_created(thread_event) + + # IM and any future regular event type: persist via serializer, + # then sync MENTION rows when applicable. + thread_event = serializer.save(thread=thread, author=request.user) + thread_events_service.sync_im_mentions(thread_event=thread_event) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def _serialize_created(self, thread_event): + """Build the 201 response from a ThreadEvent created by the service. + + The service path bypasses ``serializer.save()`` (it owns the + ThreadEvent + UserEvent atomicity) so we re-serialize the result + through a fresh serializer to keep the response shape identical + to the legacy path. + """ + serializer = self.get_serializer(thread_event) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) diff --git a/src/backend/core/api/viewsets/thread_user.py b/src/backend/core/api/viewsets/thread_user.py index b8e66b859..6af9678f0 100644 --- a/src/backend/core/api/viewsets/thread_user.py +++ b/src/backend/core/api/viewsets/thread_user.py @@ -1,9 +1,11 @@ """API ViewSet to list users who have access to a thread.""" +from django.db.models import Exists, OuterRef + from drf_spectacular.utils import extend_schema from rest_framework import mixins, viewsets -from core import models +from core import enums, models from .. import permissions, serializers @@ -15,7 +17,7 @@ class ThreadUserViewSet( ): """List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess).""" - serializer_class = serializers.UserWithoutAbilitiesSerializer + serializer_class = serializers.ThreadMentionableUserSerializer pagination_class = None permission_classes = [ permissions.IsAuthenticated, @@ -28,10 +30,21 @@ def get_queryset(self): if not thread_id: return models.User.objects.none() + # A user can post internal comments if they have at least one + # MailboxAccess with an edit role on a mailbox that itself has + # access to this thread. See _user_can_comment_on_thread in + # permissions.py for the canonical rule. + can_comment_subquery = models.MailboxAccess.objects.filter( + user=OuterRef("pk"), + role__in=enums.MAILBOX_ROLES_CAN_EDIT, + mailbox__thread_accesses__thread_id=thread_id, + ) + return ( models.User.objects.filter( mailbox_accesses__mailbox__thread_accesses__thread_id=thread_id, ) + .annotate(can_post_comments=Exists(can_comment_subquery)) .distinct() .order_by("full_name", "email") ) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index b6b90dab6..50192ee4a 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -33,6 +33,11 @@ class MailboxRoleChoices(models.IntegerChoices): MailboxRoleChoices.SENDER, MailboxRoleChoices.ADMIN, ] +MAILBOX_ROLES_CAN_BE_ASSIGNED = [ + MailboxRoleChoices.EDITOR, + MailboxRoleChoices.SENDER, + MailboxRoleChoices.ADMIN, +] class ThreadAccessRoleChoices(models.IntegerChoices): @@ -93,6 +98,8 @@ class DKIMAlgorithmChoices(models.IntegerChoices): "has_delivery_failed": "has_delivery_failed", "has_unread_mention": "has_unread_mention", "has_mention": "has_mention", + "has_assigned_to_me": "has_assigned_to_me", + "has_unassigned": "has_unassigned", } @@ -149,6 +156,8 @@ class ThreadEventTypeChoices(models.TextChoices): """Defines the possible types of thread events.""" IM = "im", "Instant message" + ASSIGN = "assign", "Assign" + UNASSIGN = "unassign", "Unassign" class ChannelScopeLevel(models.TextChoices): @@ -278,6 +287,7 @@ class UserEventTypeChoices(models.TextChoices): """Defines the possible types of user events.""" MENTION = "mention", "Mention" + ASSIGN = "assign", "Assign" def user_event_type_choices(): diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 440209e47..a24497245 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -147,16 +147,55 @@ class Meta: class ThreadEventFactory(factory.django.DjangoModelFactory): - """A factory to create thread events for testing purposes.""" + """A factory to create thread events for testing purposes. + + Mirrors the side effects that production code attaches to the + creation of these events — namely the matching ``UserEvent`` rows — + so existing tests that exercise the resulting state via the factory + keep working without having to call ``thread_events_service`` + explicitly. Production code reaches the same effect by calling the + service from viewsets / admin; this hook is the test-only + equivalent. + """ class Meta: model = models.ThreadEvent + skip_postgeneration_save = True thread = factory.SubFactory(ThreadFactory) type = "im" data = factory.LazyAttribute(lambda o: {"content": fake.sentence()}) author = factory.SubFactory(UserFactory) + # pylint: disable=protected-access,no-member + # ``self.data`` is a real dict at runtime (factory_boy resolves + # ``LazyAttribute`` before post_generation hooks fire) but pylint sees + # the class-level descriptor and reports ``no-member``. Reaching into + # the private service helpers is intentional — see the docstring above. + @factory.post_generation + def _sync_user_events(self, create, _extracted, **_kwargs): + # pylint: disable=import-outside-toplevel + from core.services import thread_events as thread_events_service + + if not create: + return + data = self.data or {} + if self.type == enums.ThreadEventTypeChoices.IM: + if data.get("mentions"): + thread_events_service.sync_im_mentions(thread_event=self) + elif self.type == enums.ThreadEventTypeChoices.ASSIGN: + assignees = data.get("assignees") or [] + if assignees: + thread_events_service._create_user_event_assigns( # noqa: SLF001 + self, self.thread, assignees + ) + elif self.type == enums.ThreadEventTypeChoices.UNASSIGN: + assignees = data.get("assignees") or [] + if assignees: + thread_events_service._delete_user_event_assigns( # noqa: SLF001 + self.thread, assignees, context=str(self.id) + ) + class UserEventFactory(factory.django.DjangoModelFactory): """A factory to create user events for testing purposes.""" diff --git a/src/backend/core/migrations/0026_userevent_usrevt_user_thread_assign_uniq.py b/src/backend/core/migrations/0026_userevent_usrevt_user_thread_assign_uniq.py new file mode 100644 index 000000000..00315325e --- /dev/null +++ b/src/backend/core/migrations/0026_userevent_usrevt_user_thread_assign_uniq.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.11 on 2026-04-22 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0025_alter_threadevent_type_userevent'), + ] + + operations = [ + migrations.AddConstraint( + model_name='userevent', + constraint=models.UniqueConstraint(condition=models.Q(('type', 'assign')), fields=('user', 'thread'), name='usrevt_user_thread_assign_uniq'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 1fcaea650..f0a768400 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -28,7 +28,6 @@ from django.utils.html import escape from django.utils.text import slugify -import jsonschema import pyzstd from encrypted_fields.fields import EncryptedJSONField, EncryptedTextField from timezone_field import TimeZoneField @@ -55,6 +54,7 @@ ) from core.mda.rfc5322 import EmailParseError, parse_email_message from core.mda.signing import generate_dkim_key as _generate_dkim_key +from core.utils import validate_json_schema logger = getLogger(__name__) @@ -228,15 +228,11 @@ def save(self, *args, **kwargs): def clean(self): """Validate fields values.""" - try: - jsonschema.validate( - self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_USER - ) - except jsonschema.ValidationError as exception: - raise ValidationError( - {"custom_attributes": exception.message} - ) from exception - + validate_json_schema( + self.custom_attributes, + settings.SCHEMA_CUSTOM_ATTRIBUTES_USER, + field="custom_attributes", + ) super().clean() def get_abilities(self): @@ -319,15 +315,11 @@ def save(self, *args, **kwargs): def clean(self): """Validate custom attributes.""" - try: - jsonschema.validate( - self.custom_attributes, settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN - ) - except jsonschema.ValidationError as exception: - raise ValidationError( - {"custom_attributes": exception.message} - ) from exception - + validate_json_schema( + self.custom_attributes, + settings.SCHEMA_CUSTOM_ATTRIBUTES_MAILDOMAIN, + field="custom_attributes", + ) super().clean() def get_spam_config(self) -> Dict[str, Any]: @@ -1457,34 +1449,56 @@ def delete(self, *args, **kwargs): class ThreadAccessQuerySet(models.QuerySet): """Custom queryset exposing reusable access-scoped filters.""" + # Shared ORM conditions that define "editor rights on a thread": + # - ThreadAccess.role == EDITOR + # - MailboxAccess.role is in `MAILBOX_ROLES_CAN_EDIT` + _EDITOR_CONDITIONS = { + "role": ThreadAccessRoleChoices.EDITOR, + "mailbox__accesses__role__in": MAILBOX_ROLES_CAN_EDIT, + } + def editable_by(self, user, mailbox_id=None): """Return ThreadAccess rows granting full edit rights to `user`. - Combines BOTH conditions, which are the single source of truth - for "can this user edit this thread?": - - - `ThreadAccess.role == EDITOR` - - The user's `MailboxAccess.role` on that mailbox is in - `MAILBOX_ROLES_CAN_EDIT` - When `mailbox_id` is provided, the result is also scoped to that mailbox. Used by the flag, label, thread and thread-event endpoints to replace ad-hoc permission checks. - - IMPORTANT: the two `mailbox__accesses__*` conditions MUST stay - inside the same `.filter()` call. If split, Django generates two - independent JOINs and the query matches any pair of mailbox - accesses satisfying either condition — a false positive. """ qs = self.filter( - role=ThreadAccessRoleChoices.EDITOR, + **self._EDITOR_CONDITIONS, mailbox__accesses__user=user, - mailbox__accesses__role__in=MAILBOX_ROLES_CAN_EDIT, ) if mailbox_id is not None: qs = qs.filter(mailbox_id=mailbox_id) return qs + def editor_user_ids(self, thread_id, user_ids=None): + """Return user ids with full edit rights on `thread_id`""" + filters = { + "thread_id": thread_id, + **self._EDITOR_CONDITIONS, + } + if user_ids is not None: + filters["mailbox__accesses__user_id__in"] = user_ids + + return ( + self.filter(**filters) + .values_list("mailbox__accesses__user_id", flat=True) + .distinct() + ) + + def viewer_user_ids(self, thread_id, user_ids=None): + """Return user ids with any access on `thread_id`""" + + filters = {"thread_id": thread_id} + if user_ids is not None: + filters["mailbox__accesses__user_id__in"] = user_ids + return ( + self.filter(**filters) + .values_list("mailbox__accesses__user_id", flat=True) + .distinct() + ) + ThreadAccessManager = models.Manager.from_queryset(ThreadAccessQuerySet) @@ -1601,32 +1615,58 @@ 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", +_ASSIGNEES_SCHEMA = { + "type": "object", + "properties": { + "assignees": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, }, - "mentions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": {"type": "string", "format": "uuid"}, - "name": {"type": "string"}, - }, - "required": ["id", "name"], - "additionalProperties": False, - }, + "required": ["id", "name"], + "additionalProperties": False, + }, + "minItems": 1, + }, + }, + "required": ["assignees"], + "additionalProperties": False, +} + +_IM_SCHEMA = { + "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": ["content"], + "additionalProperties": False, +} + + +class ThreadEvent(BaseModel): + """Thread event model to store events in a thread timeline (internal comments, notifications, etc.).""" + + DATA_SCHEMAS = { + ThreadEventTypeChoices.IM: _IM_SCHEMA, + ThreadEventTypeChoices.ASSIGN: _ASSIGNEES_SCHEMA, + ThreadEventTypeChoices.UNASSIGN: _ASSIGNEES_SCHEMA, } thread = models.ForeignKey( @@ -1672,20 +1712,31 @@ class Meta: def __str__(self): return f"{self.thread} - {self.type} - {self.created_at}" + @classmethod + def validate_data(cls, event_type, data): + """Validate ``data`` against the JSON Schema registered for ``event_type``. + + Raises ``django.core.exceptions.ValidationError`` with the offending + message keyed as ``data``. No-op when ``event_type`` has no schema + registered in ``DATA_SCHEMAS``. + + Exposed at the class level so the API serializer can reuse it and + surface 400 errors before the viewset ever reads the payload. + """ + schema = cls.DATA_SCHEMAS.get(event_type) + if schema is None: + return + validate_json_schema(data, schema, field="data") + 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 + self.validate_data(self.type, self.data) super().clean() def is_editable(self): """Return whether the event can still be edited or deleted. - The time window is controlled by ``settings.MAX_THREAD_EVENT_EDIT_DELAY`` + The time window is controlled by `settings.MAX_THREAD_EVENT_EDIT_DELAY` (in seconds). A value of 0 disables the restriction. """ delay = settings.MAX_THREAD_EVENT_EDIT_DELAY @@ -1749,6 +1800,15 @@ class Meta: fields=["user", "thread_event", "type"], name="usrevt_user_event_type_uniq", ), + # Enforce at most one active ASSIGN UserEvent per (user, thread). + # This is the schema-level guarantee behind the idempotence logic + # in ThreadEventViewSet.create and absorbs races between concurrent + # ASSIGN requests. MENTION has no such invariant (one per message). + models.UniqueConstraint( + fields=["user", "thread"], + condition=Q(type="assign"), + name="usrevt_user_thread_assign_uniq", + ), ] indexes = [ models.Index( diff --git a/src/backend/core/services/thread_events.py b/src/backend/core/services/thread_events.py new file mode 100644 index 000000000..92f8d4b75 --- /dev/null +++ b/src/backend/core/services/thread_events.py @@ -0,0 +1,650 @@ +"""Service layer for ThreadEvent operations. + +The service is the single entry point for creating, updating, +and reverting assignment / mention state; +viewsets and ``ModelAdmin`` call into it directly. +""" + +import logging +import uuid +from datetime import timedelta + +from django.db import transaction +from django.utils import timezone + +from core import enums, models + +logger = logging.getLogger(__name__) + + +# Window during which an UNASSIGN by the same author that targets a user +# freshly assigned via a recent ASSIGN ThreadEvent is treated as an "undo": +# the offending user is stripped from the original ASSIGN event (the event +# is deleted if it becomes empty) and no UNASSIGN event is emitted. Mirrors +# a classic "undo a misclick" pattern and avoids cluttering the timeline +# with back-to-back noise. +UNDO_WINDOW_SECONDS = 120 + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _parse_and_dedupe_user_ids(users_data, *, context): + """Extract unique, well-formed UUIDs from a list of user-shape dicts. + + Skips entries with missing or malformed ``id`` fields. + ``context`` is a free-form string included in the log message. + """ + seen_user_ids = set() + unique_user_ids = [] + for entry in users_data or []: + raw_id = entry.get("id") + if not raw_id: + continue + try: + user_id = uuid.UUID(raw_id) + except (ValueError, AttributeError): + logger.warning( + "Skipping user with invalid UUID '%s' in context %s", + raw_id, + context, + ) + continue + if user_id not in seen_user_ids: + seen_user_ids.add(user_id) + unique_user_ids.append(user_id) + return unique_user_ids + + +def _validate_user_ids_with_access(thread, users_data, *, context): + """Keep only user IDs that have any ThreadAccess on ``thread``. + + Used by MENTION: posting an internal comment makes sense for any user + who can read the thread, so VIEWER access is enough. Silently drops + and logs users that do not match — this is a filter, not a validator, + so callers do not need to handle a raised exception. + """ + unique_user_ids = _parse_and_dedupe_user_ids(users_data, context=context) + if not unique_user_ids: + return set() + + valid_user_ids = set( + models.ThreadAccess.objects.filter( + thread=thread, + mailbox__accesses__user_id__in=unique_user_ids, + ).values_list("mailbox__accesses__user_id", flat=True) + ) + + for user_id in unique_user_ids: + if user_id not in valid_user_ids: + logger.warning( + "Skipping user %s in context %s: user not found or no thread access", + user_id, + context, + ) + + return valid_user_ids + + +def _validate_user_ids_with_edit_rights(thread, users_data, *, context): + """Keep only user IDs that have full edit rights on ``thread``. + + Used by ASSIGN: callers (viewset, admin) enforce the same rule + upstream, so this is a defence-in-depth filter for paths that may + reach the service with stale data. + """ + unique_user_ids = _parse_and_dedupe_user_ids(users_data, context=context) + if not unique_user_ids: + return set() + + valid_user_ids = set( + models.ThreadAccess.objects.editor_user_ids(thread.id, user_ids=unique_user_ids) + ) + + for user_id in unique_user_ids: + if user_id not in valid_user_ids: + logger.warning( + "Skipping user %s in context %s: " + "user does not have edit rights on the thread", + user_id, + context, + ) + + return valid_user_ids + + +def _create_user_event_assigns(thread_event, thread, assignees_data): + """Create UserEvent ASSIGN rows for the given assignees. + + Assumes upstream validation has narrowed ``assignees_data`` to users + who hold editor rights on ``thread``. ``ignore_conflicts=True`` lets + the partial UniqueConstraint on (user, thread) WHERE type=ASSIGN + absorb concurrent ASSIGN requests racing to insert the same row. + """ + valid_user_ids = _validate_user_ids_with_edit_rights( + thread, assignees_data, context=str(thread_event.id) + ) + if not valid_user_ids: + return [] + + user_events = [ + models.UserEvent( + user_id=user_id, + thread=thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + for user_id in valid_user_ids + ] + models.UserEvent.objects.bulk_create(user_events, ignore_conflicts=True) + logger.info( + "Created %d UserEvent ASSIGN(s) for ThreadEvent %s", + len(user_events), + thread_event.id, + ) + return user_events + + +def _delete_user_event_assigns(thread, assignees_data, *, context): + """Remove UserEvent ASSIGN rows matching ``assignees_data`` on ``thread``. + + The ThreadEvent UNASSIGN row is the historical trace; the per-user + UserEvent ASSIGN is removed because it is the source of truth for + "currently assigned to me". + """ + if not assignees_data: + return 0 + + user_ids = set() + for assignee in assignees_data: + raw_id = assignee.get("id") + if not raw_id: + continue + try: + user_ids.add(uuid.UUID(raw_id)) + except (ValueError, AttributeError): + logger.warning( + "Skipping unassign with invalid UUID '%s' in context %s", + raw_id, + context, + ) + + if not user_ids: + return 0 + + deleted, _ = models.UserEvent.objects.filter( + thread=thread, + user_id__in=user_ids, + type=enums.UserEventTypeChoices.ASSIGN, + ).delete() + + if deleted: + logger.info( + "Deleted %d UserEvent ASSIGN(s) in context %s", + deleted, + context, + ) + + return deleted + + +def _sync_user_event_mentions(thread_event, thread, mentions_data): + """Reconcile UserEvent MENTION rows against the current mentions payload. + + - Creates rows for newly mentioned users. + - Deletes rows for users no longer mentioned (so stale entries do not + linger in the "Mentioned" folder after an edit). + - Leaves matching rows untouched, preserving ``read_at`` across edits. + + Invalid or unauthorised mentions are silently dropped with a warning. + """ + new_valid_user_ids = _validate_user_ids_with_access( + thread, mentions_data, context=str(thread_event.id) + ) + + existing_user_ids = set( + models.UserEvent.objects.filter( + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ).values_list("user_id", flat=True) + ) + + to_add = new_valid_user_ids - existing_user_ids + to_remove = existing_user_ids - new_valid_user_ids + + if to_remove: + deleted_count, _ = models.UserEvent.objects.filter( + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + user_id__in=to_remove, + ).delete() + if deleted_count: + logger.info( + "Deleted %d UserEvent MENTION(s) for ThreadEvent %s", + deleted_count, + thread_event.id, + ) + + if to_add: + user_events = [ + models.UserEvent( + user_id=user_id, + thread=thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + for user_id in to_add + ] + # ignore_conflicts=True absorbs the (user, thread_event, type) unique + # constraint when concurrent edits race on the same ThreadEvent. + models.UserEvent.objects.bulk_create(user_events, ignore_conflicts=True) + logger.info( + "Created %d UserEvent MENTION(s) for ThreadEvent %s", + len(user_events), + thread_event.id, + ) + + +def _absorb_unassign_in_undo_window(*, thread, author, assignee_ids, assignees_data): + """Absorb an UNASSIGN against recent ASSIGN events by the same author. + + When an UNASSIGN arrives within ``UNDO_WINDOW_SECONDS`` after an ASSIGN + by the same author for the same user, treat it as an undo: the user is + stripped from the original ASSIGN event (deleted if it becomes empty) + and the matching ``UserEvent ASSIGN`` row is removed. No UNASSIGN + ThreadEvent is emitted for these users. + + Returns the set of absorbed user UUIDs. Locks recent ASSIGN rows with + ``select_for_update`` so concurrent requests cannot double-undo. + """ + if not assignee_ids: + return set() + + cutoff = timezone.now() - timedelta(seconds=UNDO_WINDOW_SECONDS) + target_ids = set(assignee_ids) + recent_assigns = list( + models.ThreadEvent.objects.select_for_update() + .filter( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.ASSIGN, + created_at__gte=cutoff, + ) + .order_by("-created_at") + ) + + absorbed = set() + for event in recent_assigns: + original_assignees = (event.data or {}).get("assignees", []) + if not original_assignees: + continue + remaining = [] + changed = False + for assignee in original_assignees: + try: + aid = uuid.UUID(assignee["id"]) + except (ValueError, KeyError, TypeError): + remaining.append(assignee) + continue + if aid in target_ids and aid not in absorbed: + absorbed.add(aid) + changed = True + else: + remaining.append(assignee) + if not changed: + continue + if remaining: + event.data = {**event.data, "assignees": remaining} + event.save() + else: + event.delete() + + if absorbed: + absorbed_data = [a for a in assignees_data if uuid.UUID(a["id"]) in absorbed] + _delete_user_event_assigns(thread, absorbed_data, context="") + + return absorbed + + +# --------------------------------------------------------------------------- +# Public service API — ASSIGN / UNASSIGN +# --------------------------------------------------------------------------- + + +@transaction.atomic +def assign_users(*, thread, author, assignees_data): + """Assign users to ``thread`` by creating ASSIGN events. + + - Idempotent: users already holding a ``UserEvent ASSIGN`` on the + thread are filtered out. If every requested assignee is already + assigned, returns ``None`` (the caller should respond 204). + - Validates that every new assignee has full edit rights — if any + lacks them, raises ``ValueError``. The viewset translates this to + a 400; admin paths surface it as a Django form error. + - Persists a single ThreadEvent ASSIGN containing the new assignees + and creates the matching ``UserEvent`` rows in the same atomic + transaction. + + Returns the persisted ThreadEvent, or ``None`` when nothing was new. + """ + if not assignees_data: + return None + + assignee_ids = [uuid.UUID(a["id"]) for a in assignees_data] + already_assigned = set( + models.UserEvent.objects.filter( + thread=thread, + user_id__in=assignee_ids, + type=enums.UserEventTypeChoices.ASSIGN, + ).values_list("user_id", flat=True) + ) + new_assignees = [ + a for a in assignees_data if uuid.UUID(a["id"]) not in already_assigned + ] + if not new_assignees: + return None + + new_assignee_ids = {uuid.UUID(a["id"]) for a in new_assignees} + editable_user_ids = set( + models.ThreadAccess.objects.editor_user_ids( + thread.id, user_ids=new_assignee_ids + ) + ) + if editable_user_ids != new_assignee_ids: + raise ValueError("Assignee must have editor access on the thread") + + thread_event = models.ThreadEvent.objects.create( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.ASSIGN, + data={"assignees": new_assignees}, + ) + _create_user_event_assigns(thread_event, thread, new_assignees) + return thread_event + + +@transaction.atomic +def unassign_users(*, thread, author, assignees_data): + """Unassign users from ``thread`` by creating an UNASSIGN event. + + - Filters the payload down to users currently holding a + ``UserEvent ASSIGN`` on the thread; everything else is a no-op. + - Within ``UNDO_WINDOW_SECONDS`` of a recent ASSIGN by the same + author, falls back to the "undo" flow: the original ASSIGN event + is amended (or deleted if empty), no UNASSIGN ThreadEvent is + emitted, and the matching ``UserEvent ASSIGN`` rows are removed. + - Otherwise persists a single ThreadEvent UNASSIGN and deletes the + matching ``UserEvent`` rows in the same atomic transaction. + + Returns the persisted ThreadEvent, or ``None`` when the request was + fully absorbed by undo or matched no active assignment. + """ + if not assignees_data: + return None + + assignee_ids = [uuid.UUID(a["id"]) for a in assignees_data] + active_assignee_ids = set( + models.UserEvent.objects.filter( + thread=thread, + user_id__in=assignee_ids, + type=enums.UserEventTypeChoices.ASSIGN, + ).values_list("user_id", flat=True) + ) + if not active_assignee_ids: + return None + + assignees_data = [ + a for a in assignees_data if uuid.UUID(a["id"]) in active_assignee_ids + ] + assignee_ids = [uuid.UUID(a["id"]) for a in assignees_data] + + absorbed = _absorb_unassign_in_undo_window( + thread=thread, + author=author, + assignee_ids=assignee_ids, + assignees_data=assignees_data, + ) + if absorbed: + assignees_data = [ + a for a in assignees_data if uuid.UUID(a["id"]) not in absorbed + ] + if not assignees_data: + return None + + thread_event = models.ThreadEvent.objects.create( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.UNASSIGN, + data={"assignees": assignees_data}, + ) + _delete_user_event_assigns(thread, assignees_data, context=str(thread_event.id)) + return thread_event + + +# --------------------------------------------------------------------------- +# Public service API — IM (mentions) +# --------------------------------------------------------------------------- + + +@transaction.atomic +def sync_im_mentions(*, thread_event): + """Reconcile ``UserEvent MENTION`` rows for an IM ThreadEvent. + + Called by the viewset both on create (after a new IM is persisted) + and on update (after an edit changes the mentions list). Idempotent: + a re-sync with unchanged mentions does nothing. + """ + if thread_event.type != enums.ThreadEventTypeChoices.IM: + return + mentions_data = (thread_event.data or {}).get("mentions", []) or [] + _sync_user_event_mentions(thread_event, thread_event.thread, mentions_data) + + +# --------------------------------------------------------------------------- +# Public service API — access cleanup +# --------------------------------------------------------------------------- + + +def _cleanup_invalid_assignments(thread, user_ids): + """Unassign users that lost full edit rights on ``thread``. + + Among ``user_ids``, keeps only those currently assigned *and* no + longer qualifying as editors, then records a single system + ``ThreadEvent(type=UNASSIGN, author=None)`` grouping all of them and + removes the matching ``UserEvent ASSIGN`` rows. A user reachable + through multiple mailboxes keeps the assignment as long as one path + still grants editor rights. + """ + user_ids = set(user_ids) + if not user_ids: + return + + assigned_user_ids = set( + models.UserEvent.objects.filter( + thread=thread, + user_id__in=user_ids, + type=enums.UserEventTypeChoices.ASSIGN, + ).values_list("user_id", flat=True) + ) + if not assigned_user_ids: + return + + still_editors = set( + models.ThreadAccess.objects.editor_user_ids( + thread.id, user_ids=assigned_user_ids + ) + ) + to_unassign = assigned_user_ids - still_editors + if not to_unassign: + return + + users = models.User.objects.filter(id__in=to_unassign).values( + "id", "full_name", "email" + ) + assignees_data = [ + {"id": str(u["id"]), "name": u["full_name"] or u["email"] or ""} for u in users + ] + if not assignees_data: + return + + thread_event = models.ThreadEvent.objects.create( + thread=thread, + type=enums.ThreadEventTypeChoices.UNASSIGN, + author=None, + data={"assignees": assignees_data}, + ) + _delete_user_event_assigns(thread, assignees_data, context=str(thread_event.id)) + logger.info( + "Auto-unassigned %d user(s) on thread %s after access change", + len(assignees_data), + thread.id, + ) + + +def _cleanup_invalid_mentions(thread, user_ids): + """Remove ``UserEvent MENTION`` rows for users that lost access to + ``thread``. + + Unlike ``_cleanup_invalid_assignments``, no system ``ThreadEvent`` is + recorded: the IM events containing the mention payload stay + untouched as historical record; only the per-user notification rows + are dropped. A user reachable through multiple mailboxes keeps their + mentions as long as one path still grants any access. + """ + user_ids = set(user_ids) + if not user_ids: + return + + mentioned_user_ids = set( + models.UserEvent.objects.filter( + thread=thread, + user_id__in=user_ids, + type=enums.UserEventTypeChoices.MENTION, + ) + .values_list("user_id", flat=True) + .distinct() + ) + if not mentioned_user_ids: + return + + still_with_access = set( + models.ThreadAccess.objects.viewer_user_ids( + thread.id, user_ids=mentioned_user_ids + ) + ) + to_clean = mentioned_user_ids - still_with_access + if not to_clean: + return + + deleted, _ = models.UserEvent.objects.filter( + thread=thread, + user_id__in=to_clean, + type=enums.UserEventTypeChoices.MENTION, + ).delete() + if deleted: + logger.info( + "Auto-cleaned %d UserEvent MENTION(s) on thread %s after access change", + deleted, + thread.id, + ) + + +def _affected_user_ids_for_mailbox(mailbox): + """Users that reach a thread through ``mailbox`` via MailboxAccess.""" + return list(mailbox.accesses.values_list("user_id", flat=True)) + + +@transaction.atomic +def revoke_thread_access(*, thread_access): + """Cleanup assignments and mentions after a ThreadAccess deletion. + + Must be called *after* the row has been deleted (in the same atomic + transaction): the ``editor_user_ids`` / ``viewer_user_ids`` queries + that decide who lost their rights need the deleted row to be gone. + The in-memory instance still carries ``mailbox`` and ``thread`` so + we can enumerate impacted users. + """ + user_ids = _affected_user_ids_for_mailbox(thread_access.mailbox) + _cleanup_invalid_assignments(thread_access.thread, user_ids) + _cleanup_invalid_mentions(thread_access.thread, user_ids) + + +@transaction.atomic +def downgrade_thread_access(*, thread_access): + """Cleanup assignments after a ThreadAccess loses EDITOR role. + + The caller must invoke this only after the role has actually moved + away from EDITOR. Mentions are not cleaned up here: a downgrade + EDITOR → VIEWER still grants read access, so mentions remain valid. + """ + user_ids = _affected_user_ids_for_mailbox(thread_access.mailbox) + _cleanup_invalid_assignments(thread_access.thread, user_ids) + + +def _threads_with_user_event(*, mailbox_id, user_id, event_type): + """Threads where ``user_id`` holds a UserEvent of ``event_type`` and + reaches the thread through ``mailbox_id``. + + Narrows to the relevant subset to avoid iterating over every thread + shared with the mailbox — a user is typically assigned/mentioned on a + small fraction of them. + """ + return models.Thread.objects.filter( + accesses__mailbox_id=mailbox_id, + user_events__user_id=user_id, + user_events__type=event_type, + ).distinct() + + +@transaction.atomic +def revoke_mailbox_access(*, mailbox_access): + """Cleanup assignments and mentions after a MailboxAccess deletion. + + Must be called *after* the row has been deleted (in the same atomic + transaction). The in-memory instance still carries ``user_id`` and + ``mailbox_id`` so we can scope the cleanup; the deleted row is + excluded from ``editor_user_ids`` / ``viewer_user_ids`` because it + no longer exists in the database. + """ + mailbox_id = mailbox_access.mailbox_id + user_id = mailbox_access.user_id + + assigned_threads = _threads_with_user_event( + mailbox_id=mailbox_id, + user_id=user_id, + event_type=enums.UserEventTypeChoices.ASSIGN, + ) + for thread in assigned_threads: + _cleanup_invalid_assignments(thread, [user_id]) + + mentioned_threads = _threads_with_user_event( + mailbox_id=mailbox_id, + user_id=user_id, + event_type=enums.UserEventTypeChoices.MENTION, + ) + for thread in mentioned_threads: + _cleanup_invalid_mentions(thread, [user_id]) + + +@transaction.atomic +def downgrade_mailbox_access(*, mailbox_access): + """Cleanup assignments after a MailboxAccess role left + ``MAILBOX_ROLES_CAN_EDIT``. + + The caller must invoke this only after the role has actually changed + out of editing roles. Mentions stay untouched: every MailboxAccess + role grants read access, so a downgrade alone never invalidates a + mention. + """ + mailbox_id = mailbox_access.mailbox_id + user_id = mailbox_access.user_id + + assigned_threads = _threads_with_user_event( + mailbox_id=mailbox_id, + user_id=user_id, + event_type=enums.UserEventTypeChoices.ASSIGN, + ) + for thread in assigned_threads: + _cleanup_invalid_assignments(thread, [user_id]) diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index b0e80fcfb..66f7f330f 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -2,7 +2,6 @@ # pylint: disable=unused-argument import logging -import uuid from django.conf import settings from django.db import transaction @@ -260,155 +259,3 @@ def delete_user_scope_channels_on_user_delete(sender, instance, **kwargs): user=instance, scope_level=enums.ChannelScopeLevel.USER, ).delete() - - -def _validate_user_ids_with_access(thread_event, thread, users_data): - """Validate and deduplicate user IDs, checking ThreadAccess. - - Shared validation logic for mentions. Parses UUIDs from - the 'id' field of each entry, deduplicates, and batch-validates that each - user has access to the thread via the MailboxAccess -> ThreadAccess chain. - - Args: - thread_event: The ThreadEvent instance (for logging context). - thread: The Thread instance. - users_data: List of dicts with 'id' and 'name' keys. - - Returns: - Set of valid user UUIDs that have access to the thread. - """ - if not users_data: - return set() - - seen_user_ids = set() - unique_user_ids = [] - for entry in users_data: - raw_id = entry.get("id") - if not raw_id: - continue - try: - user_id = uuid.UUID(raw_id) - except (ValueError, AttributeError): - logger.warning( - "Skipping user with invalid UUID '%s' in ThreadEvent %s", - raw_id, - thread_event.id, - ) - continue - if user_id not in seen_user_ids: - seen_user_ids.add(user_id) - unique_user_ids.append(user_id) - - if not unique_user_ids: - return set() - - # Batch validate: users who have access to this thread - # Chain: User -> MailboxAccess.user -> MailboxAccess.mailbox -> ThreadAccess.mailbox - valid_user_ids = set( - models.ThreadAccess.objects.filter( - thread=thread, - mailbox__accesses__user_id__in=unique_user_ids, - ).values_list("mailbox__accesses__user_id", flat=True) - ) - - for user_id in unique_user_ids: - if user_id not in valid_user_ids: - logger.warning( - "Skipping user %s in ThreadEvent %s: " - "user not found or no thread access", - user_id, - thread_event.id, - ) - - return valid_user_ids - - -def sync_mention_user_events(thread_event, thread, mentions_data): - """Sync UserEvent MENTION records to match the current mentions payload. - - Diffs the currently mentioned users against the existing UserEvent MENTION - records for this ThreadEvent and reconciles the two: - - Creates UserEvent records for newly mentioned users. - - Deletes UserEvent records for users who are no longer mentioned so that - stale entries do not linger in the "Mentioned" folder after an edit. - - Leaves existing records untouched when the user is still mentioned, which - preserves their ``read_at`` state across edits. - - Invalid or unauthorized mentions are silently skipped with a warning log. - - Args: - thread_event: The ThreadEvent instance containing mentions. - thread: The Thread instance. - mentions_data: List of mention dicts with 'id' and 'name' keys. - """ - new_valid_user_ids = _validate_user_ids_with_access( - thread_event, thread, mentions_data - ) - - existing_user_ids = set( - models.UserEvent.objects.filter( - thread_event=thread_event, - type=enums.UserEventTypeChoices.MENTION, - ).values_list("user_id", flat=True) - ) - - to_add = new_valid_user_ids - existing_user_ids - to_remove = existing_user_ids - new_valid_user_ids - - if to_remove: - deleted_count, _ = models.UserEvent.objects.filter( - thread_event=thread_event, - type=enums.UserEventTypeChoices.MENTION, - user_id__in=to_remove, - ).delete() - if deleted_count: - logger.info( - "Deleted %d UserEvent MENTION(s) for ThreadEvent %s", - deleted_count, - thread_event.id, - ) - - if to_add: - user_events = [ - models.UserEvent( - user_id=user_id, - thread=thread, - thread_event=thread_event, - type=enums.UserEventTypeChoices.MENTION, - ) - for user_id in to_add - ] - # ignore_conflicts=True lets the UniqueConstraint on - # (user, thread_event, type) absorb races between concurrent - # post_save signals on the same ThreadEvent (e.g. two PATCH in flight). - models.UserEvent.objects.bulk_create(user_events, ignore_conflicts=True) - logger.info( - "Created %d UserEvent MENTION(s) for ThreadEvent %s", - len(user_events), - thread_event.id, - ) - - -@receiver(post_save, sender=models.ThreadEvent) -def handle_thread_event_post_save(sender, instance, created, **kwargs): - """Handle post-save signal for ThreadEvent to sync UserEvent records. - - Dispatches by ThreadEvent type: - - IM: Syncs UserEvent MENTION records on both create and update so that - edits to the mentions list add/remove notifications accordingly. - """ - try: - if instance.type == enums.ThreadEventTypeChoices.IM: - sync_mention_user_events( - thread_event=instance, - thread=instance.thread, - mentions_data=(instance.data or {}).get("mentions", []), - ) - - # pylint: disable=broad-exception-caught - except Exception as e: - logger.exception( - "Error in ThreadEvent post_save handler for event %s: %s", - instance.id, - e, - ) diff --git a/src/backend/core/tests/api/test_mailboxes.py b/src/backend/core/tests/api/test_mailboxes.py index fe793fae7..ce4ae02cd 100644 --- a/src/backend/core/tests/api/test_mailboxes.py +++ b/src/backend/core/tests/api/test_mailboxes.py @@ -146,6 +146,60 @@ def test_list_is_identity_false(self): assert response.status_code == status.HTTP_200_OK assert response.data[0]["is_identity"] is False + def test_list_is_shared_solo_identity_is_false(self): + """An identity mailbox with a single access is not shared.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory(is_identity=True) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=models.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(reverse("mailboxes-list")) + assert response.status_code == status.HTTP_200_OK + assert response.data[0]["is_shared"] is False + + def test_list_is_shared_identity_with_multiple_accesses_is_true(self): + """An identity mailbox shared via delegation (>1 access) is shared.""" + user = factories.UserFactory() + delegate = factories.UserFactory() + mailbox = factories.MailboxFactory(is_identity=True) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=models.MailboxRoleChoices.EDITOR, + ) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=delegate, + role=models.MailboxRoleChoices.VIEWER, + ) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(reverse("mailboxes-list")) + assert response.status_code == status.HTTP_200_OK + assert response.data[0]["is_shared"] is True + + def test_list_is_shared_non_identity_is_true(self): + """A non-identity mailbox is always shared, even with a single access.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory(is_identity=False) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=models.MailboxRoleChoices.EDITOR, + ) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(reverse("mailboxes-list")) + assert response.status_code == status.HTTP_200_OK + assert response.data[0]["is_shared"] is True + def test_list_unauthorized(self): """Anonymous user cannot access the list of mailboxes.""" client = APIClient() diff --git a/src/backend/core/tests/api/test_provisioning_mailbox.py b/src/backend/core/tests/api/test_provisioning_mailbox.py index d0d311669..3dfa44ed9 100644 --- a/src/backend/core/tests/api/test_provisioning_mailbox.py +++ b/src/backend/core/tests/api/test_provisioning_mailbox.py @@ -209,7 +209,14 @@ def test_response_fields(self, client, auth_header, mailbox): ) result = response.json()["results"][0] - assert set(result.keys()) == {"id", "email", "name", "role", "users"} + assert set(result.keys()) == { + "id", + "email", + "name", + "role", + "users", + "is_identity", + } def test_users_includes_all_mailbox_users(self, client, auth_header, mailbox): """The users array lists ALL users with access, not just the queried one.""" diff --git a/src/backend/core/tests/api/test_thread_access.py b/src/backend/core/tests/api/test_thread_access.py index 0566f14f4..544031d99 100644 --- a/src/backend/core/tests/api/test_thread_access.py +++ b/src/backend/core/tests/api/test_thread_access.py @@ -1,4 +1,5 @@ """Tests for the ThreadAccess API endpoints.""" +# pylint: disable=too-many-lines import threading import uuid @@ -94,11 +95,15 @@ def test_list_thread_access_success( ) factories.ThreadAccessFactory.create_batch(5, thread=other_thread) - with django_assert_num_queries(3): + # Query count is bounded (no N+1): prefetch chain covers mailbox, + # domain, contact, mailbox accesses and their users in a fixed + # number of queries regardless of the number of thread accesses. + with django_assert_num_queries(5): response = api_client.get(get_thread_access_url(thread.id)) assert response.status_code == status.HTTP_200_OK - assert response.data["count"] == 11 - assert response.data["results"][0]["thread"] == thread.id + assert len(response.data) == 11 + # Assignable serializer must expose the per-mailbox `users` payload. + assert "users" in response.data[0] def test_list_thread_access_filter_by_mailbox( self, api_client, thread_with_editor_access, django_assert_num_queries @@ -119,13 +124,13 @@ def test_list_thread_access_filter_by_mailbox( thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR, ) - with django_assert_num_queries(3): + with django_assert_num_queries(5): response = api_client.get( f"{get_thread_access_url(thread.id)}?mailbox_id={mailbox.id}" ) assert response.status_code == status.HTTP_200_OK - assert response.data["count"] == 1 - assert response.data["results"][0]["mailbox"] == mailbox.id + assert len(response.data) == 1 + assert response.data[0]["mailbox"] == mailbox.id @pytest.mark.parametrize( "thread_access_role, mailbox_access_role", @@ -866,3 +871,189 @@ def delete_access(name, url): role=enums.ThreadAccessRoleChoices.EDITOR, ).count() assert remaining == 1 + + +class TestThreadAccessDetailUsersField: + """Verify the `users` field exposed on ThreadAccessDetailSerializer. + + This field feeds the share/assignment modal: it lists users of each + mailbox-with-access who can be assigned to the thread, excluding + viewers (they cannot be assignees). It is served only by the thread + accesses list endpoint so thread list/retrieve payloads stay lean. + """ + + def _list_thread_accesses(self, api_client, thread_id): + """GET /api/v1.0/threads/{thread_id}/accesses/""" + return api_client.get(get_thread_access_url(thread_id)) + + def test_excludes_viewers_includes_higher_roles(self, api_client): + """Viewers on a mailbox must not appear in `users`; editors/senders/admins must.""" + requester = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=requester, + role=enums.MailboxRoleChoices.ADMIN, + ) + + viewer_user = factories.UserFactory(full_name="Zoé Viewer") + editor_user = factories.UserFactory(full_name="Alice Editor") + sender_user = factories.UserFactory(full_name="Bob Sender") + admin_user = factories.UserFactory(full_name="Carol Admin") + factories.MailboxAccessFactory( + mailbox=mailbox, user=viewer_user, role=enums.MailboxRoleChoices.VIEWER + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=editor_user, role=enums.MailboxRoleChoices.EDITOR + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=sender_user, role=enums.MailboxRoleChoices.SENDER + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=admin_user, role=enums.MailboxRoleChoices.ADMIN + ) + + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=requester) + response = self._list_thread_accesses(api_client, thread.id) + assert response.status_code == status.HTTP_200_OK + + access = response.data[0] + returned_ids = {str(u["id"]) for u in access["users"]} + assert str(viewer_user.id) not in returned_ids + assert str(editor_user.id) in returned_ids + assert str(sender_user.id) in returned_ids + assert str(admin_user.id) in returned_ids + # Requester (admin) is included too. + assert str(requester.id) in returned_ids + + def test_users_field_is_sorted(self, api_client): + """Users must be ordered by full_name then email.""" + requester = factories.UserFactory(full_name="Zed") + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=requester, + role=enums.MailboxRoleChoices.ADMIN, + ) + # Create users out of alphabetical order to ensure ordering is + # driven by the serializer, not insertion order. + names = ["Charlie", "Alice", "Bob"] + for name in names: + factories.MailboxAccessFactory( + mailbox=mailbox, + user=factories.UserFactory(full_name=name), + role=enums.MailboxRoleChoices.EDITOR, + ) + + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=requester) + response = self._list_thread_accesses(api_client, thread.id) + assert response.status_code == status.HTTP_200_OK + returned_names = [u["full_name"] for u in response.data[0]["users"]] + assert returned_names == ["Alice", "Bob", "Charlie", "Zed"] + + def test_users_field_respects_mailbox_boundary(self, api_client): + """Each mailbox-access must only list its own mailbox users.""" + requester = factories.UserFactory() + mailbox_a = factories.MailboxFactory() + mailbox_b = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox_a, + user=requester, + role=enums.MailboxRoleChoices.ADMIN, + ) + factories.MailboxAccessFactory( + mailbox=mailbox_b, + user=requester, + role=enums.MailboxRoleChoices.ADMIN, + ) + a_only = factories.UserFactory(full_name="Only A") + b_only = factories.UserFactory(full_name="Only B") + factories.MailboxAccessFactory( + mailbox=mailbox_a, user=a_only, role=enums.MailboxRoleChoices.EDITOR + ) + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=b_only, role=enums.MailboxRoleChoices.EDITOR + ) + + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_a, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + factories.ThreadAccessFactory( + mailbox=mailbox_b, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=requester) + response = self._list_thread_accesses(api_client, thread.id) + assert response.status_code == status.HTTP_200_OK + + accesses_by_mailbox = {str(a["mailbox"]): a for a in response.data} + a_users = { + str(u["id"]) for u in accesses_by_mailbox[str(mailbox_a.id)]["users"] + } + b_users = { + str(u["id"]) for u in accesses_by_mailbox[str(mailbox_b.id)]["users"] + } + + assert str(a_only.id) in a_users + assert str(a_only.id) not in b_users + assert str(b_only.id) in b_users + + def test_thread_endpoints_do_not_expose_users(self, api_client): + """`users` is served only by the accesses list endpoint. + + Both `GET /threads/` and `GET /threads/{id}/` embed accesses via + `ThreadAccessDetailSerializer`, which intentionally omits `users` + so thread payloads stay small and free of per-mailbox user PII. + """ + requester = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=requester, + role=enums.MailboxRoleChoices.ADMIN, + ) + factories.MailboxAccessFactory( + mailbox=mailbox, + user=factories.UserFactory(), + role=enums.MailboxRoleChoices.EDITOR, + ) + + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + api_client.force_authenticate(user=requester) + + list_url = reverse("threads-list") + list_response = api_client.get(f"{list_url}?mailbox_id={mailbox.id}") + assert list_response.status_code == status.HTTP_200_OK + list_access = list_response.data["results"][0]["accesses"][0] + assert "users" not in list_access + + detail_url = reverse("threads-detail", kwargs={"pk": thread.id}) + detail_response = api_client.get(f"{detail_url}?mailbox_id={mailbox.id}") + assert detail_response.status_code == status.HTTP_200_OK + detail_access = detail_response.data["accesses"][0] + assert "users" not in detail_access diff --git a/src/backend/core/tests/api/test_thread_event.py b/src/backend/core/tests/api/test_thread_event.py index 15e7fbece..07003a508 100644 --- a/src/backend/core/tests/api/test_thread_event.py +++ b/src/backend/core/tests/api/test_thread_event.py @@ -1,7 +1,9 @@ """Tests for the ThreadEvent API endpoints.""" +# pylint: disable=too-many-lines import uuid from datetime import timedelta +from unittest.mock import patch from django.test import override_settings from django.urls import reverse @@ -11,6 +13,8 @@ from rest_framework import status from core import enums, factories, models +from core.api.serializers import ThreadEventSerializer +from core.services.thread_events import UNDO_WINDOW_SECONDS pytestmark = pytest.mark.django_db @@ -21,6 +25,13 @@ def _force_created_at(event, created_at): event.refresh_from_db() +def _force_past_undo_window(event): + """Push ``event`` just past the undo window so it cannot be absorbed.""" + _force_created_at( + event, timezone.now() - timedelta(seconds=UNDO_WINDOW_SECONDS + 1) + ) + + def get_thread_event_url(thread_id, event_id=None): """Helper function to get the thread event URL.""" if event_id: @@ -132,6 +143,30 @@ def test_create_thread_event_with_invalid_type(self, api_client): 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_assign_rejects_non_uuid_id(self, api_client): + """An ASSIGN payload carrying a malformed assignee id must return 400. + + Without the ``FormatChecker`` wired into ``ThreadEvent.validate_data`` + the schema's ``"format": "uuid"`` would be ignored and the viewset + would crash later on ``uuid.UUID(a["id"])``. + """ + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": { + "assignees": [ + {"id": str(uuid.uuid4()), "name": "Alice"}, + {"id": "not-a-uuid", "name": "Broken"}, + ] + }, + } + + 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_thread_event_forbidden(self, api_client): """Test creating a thread event without thread access.""" user = factories.UserFactory() @@ -143,6 +178,31 @@ def test_create_thread_event_forbidden(self, api_client): response = api_client.post(get_thread_event_url(thread.id), data, format="json") assert response.status_code == status.HTTP_403_FORBIDDEN + def test_create_im_event_mailbox_viewer_forbidden(self, api_client): + """A mailbox VIEWER cannot post an IM even with EDITOR ThreadAccess. + + ``HasThreadEventWriteAccess`` requires the user to hold a mailbox role + in ``MAILBOX_ROLES_CAN_EDIT`` to author internal comments — ThreadAccess + alone never grants commenting rights. + """ + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.VIEWER + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + api_client.force_authenticate(user=user) + + 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() @@ -236,6 +296,31 @@ def test_update_thread_event_type_readonly_on_update(self, api_client): event.refresh_from_db() assert event.type == "im" + def test_update_cannot_reattach_event_to_another_thread(self, api_client): + """``thread`` is read-only on the serializer: a PATCH carrying a + different ``thread`` id must be silently ignored, preventing an + attacker with write access on thread A from moving one of its + events onto thread B (possibly bypassing thread B's ACL).""" + 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, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event = factories.ThreadEventFactory(thread=thread, author=user, type="im") + + 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 _grant_thread_access(thread): """Create a user with edit access to ``thread`` so they can be mentioned.""" @@ -588,6 +673,33 @@ def test_create_im_event_content_not_string(self, api_client): assert response.status_code == status.HTTP_400_BAD_REQUEST assert "data" in response.data + def test_serializer_validate_dispatches_validate_data_for_im(self): + """``ThreadEventSerializer.validate()`` routes IM payloads through + ``ThreadEvent.validate_data``. + + Exercised on the serializer in isolation so a regression that drops + ``validate()`` and falls back to model ``full_clean()`` would surface + here — the API-level tests above cannot tell the two paths apart. + """ + payload = {"type": "im", "data": {"content": "hello"}} + with patch.object(models.ThreadEvent, "validate_data") as mock_validate: + serializer = ThreadEventSerializer(data=payload) + assert serializer.is_valid(), serializer.errors + + mock_validate.assert_called_once_with("im", {"content": "hello"}) + + def test_serializer_validate_dispatches_validate_data_for_assign(self): + """Same wiring for ASSIGN; covers UNASSIGN by code-path equivalence + (both share ``_ASSIGNEES_SCHEMA`` in ``ThreadEvent.DATA_SCHEMAS``). + """ + assignee = {"id": str(uuid.uuid4()), "name": "Jane"} + payload = {"type": "assign", "data": {"assignees": [assignee]}} + with patch.object(models.ThreadEvent, "validate_data") as mock_validate: + serializer = ThreadEventSerializer(data=payload) + assert serializer.is_valid(), serializer.errors + + mock_validate.assert_called_once_with("assign", {"assignees": [assignee]}) + def get_read_mention_url(thread_id, event_id): """Helper to build the read-mention URL for a thread event.""" @@ -713,3 +825,808 @@ def test_read_mention_unauthenticated(self, api_client): status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN, ) + + +class TestThreadEventAssign: + """Test the POST /threads/{thread_id}/events/ endpoint for ASSIGN events.""" + + def test_create_assign_success(self, api_client): + """POST type=assign with valid assignee returns 201 and creates UserEvent ASSIGN.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + 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"] == "assign" + + # Verify UserEvent ASSIGN was created by signal + assert ( + models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 1 + ) + + def test_create_assign_multiple_assignees(self, api_client): + """POST type=assign with multiple assignees creates UserEvent for each.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee1 = factories.UserFactory() + assignee2 = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee1, role=enums.MailboxRoleChoices.ADMIN + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee2, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": { + "assignees": [ + {"id": str(assignee1.id), "name": "A1"}, + {"id": str(assignee2.id), "name": "A2"}, + ] + }, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + + assert ( + models.UserEvent.objects.filter( + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 2 + ) + + def test_create_assign_self_assign(self, api_client): + """POST type=assign where assignee is the author (self-assign) returns 201 per D-07.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(user.id), "name": "Self"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_201_CREATED + assert ( + models.UserEvent.objects.filter( + user=user, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 1 + ) + + def test_assign_idempotent(self, api_client): + """POST type=assign when all assignees already assigned returns 204 without creating ThreadEvent (D-08).""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + + # First assign + response1 = api_client.post( + get_thread_event_url(thread.id), data, format="json" + ) + assert response1.status_code == status.HTTP_201_CREATED + + # Second assign (same assignee) - should be idempotent + response2 = api_client.post( + get_thread_event_url(thread.id), data, format="json" + ) + assert response2.status_code == status.HTTP_204_NO_CONTENT + + # Only 1 ThreadEvent ASSIGN should exist + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 1 + ) + + def test_assign_partial_idempotent(self, api_client): + """POST type=assign with mix of already-assigned and new assignees returns 201 with only new assignees.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee_a = factories.UserFactory() + assignee_b = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee_a, role=enums.MailboxRoleChoices.ADMIN + ) + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee_b, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + # First assign user A + data_a = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee_a.id), "name": "A"}]}, + } + response1 = api_client.post( + get_thread_event_url(thread.id), data_a, format="json" + ) + assert response1.status_code == status.HTTP_201_CREATED + + # Assign [A, B] - A is already assigned, B is new + data_ab = { + "type": "assign", + "data": { + "assignees": [ + {"id": str(assignee_a.id), "name": "A"}, + {"id": str(assignee_b.id), "name": "B"}, + ] + }, + } + response2 = api_client.post( + get_thread_event_url(thread.id), data_ab, format="json" + ) + assert response2.status_code == status.HTTP_201_CREATED + + # 2 ThreadEvent ASSIGN should exist (first for A, second for B only) + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 2 + ) + + # Second event data should contain only B + second_event = ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + .order_by("created_at") + .last() + ) + assignee_ids_in_event = [a["id"] for a in second_event.data["assignees"]] + assert str(assignee_b.id) in assignee_ids_in_event + assert str(assignee_a.id) not in assignee_ids_in_event + + def test_assign_visible_in_timeline(self, api_client): + """After ASSIGN, GET events returns the ASSIGN event in the timeline.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + api_client.post(get_thread_event_url(thread.id), data, format="json") + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + assign_events = [e for e in response.data if e["type"] == "assign"] + assert len(assign_events) == 1 + assert assign_events[0]["data"]["assignees"][0]["id"] == str(assignee.id) + + def test_assign_viewer_forbidden(self, api_client): + """Viewer role POST type=assign returns 403 per D-02.""" + user, mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.VIEWER + ) + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_assign_rejects_assignee_without_thread_access(self, api_client): + """Assigning a user that has no ThreadAccess at all returns 400.""" + user, _mailbox, thread = setup_user_with_thread_access() + no_access_user = factories.UserFactory() + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(no_access_user.id), "name": "NoAccess"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # No ThreadEvent nor UserEvent created. + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 0 + ) + assert ( + models.UserEvent.objects.filter( + user=no_access_user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + def test_assign_rejects_assignee_with_viewer_mailbox_role(self, api_client): + """Assigning a user whose MailboxAccess role is VIEWER returns 400.""" + user, mailbox, thread = setup_user_with_thread_access() + viewer_assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=viewer_assignee, + role=enums.MailboxRoleChoices.VIEWER, + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(viewer_assignee.id), "name": "Viewer"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 0 + ) + + def test_assign_rejects_assignee_with_viewer_thread_access(self, api_client): + """Assigning a user reachable only through a VIEWER ThreadAccess returns 400.""" + user, _mailbox, thread = setup_user_with_thread_access() + viewer_mailbox = factories.MailboxFactory() + viewer_assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=viewer_mailbox, + user=viewer_assignee, + role=enums.MailboxRoleChoices.ADMIN, + ) + factories.ThreadAccessFactory( + mailbox=viewer_mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + api_client.force_authenticate(user=user) + + data = { + "type": "assign", + "data": {"assignees": [{"id": str(viewer_assignee.id), "name": "Viewer"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestThreadEventUnassign: + """Test the POST /threads/{thread_id}/events/ endpoint for UNASSIGN events.""" + + def test_create_unassign_success(self, api_client): + """First assign, then unassign returns 201 and deletes the ASSIGN UserEvent.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + # First assign + assign_data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + api_client.post(get_thread_event_url(thread.id), assign_data, format="json") + + # Push the ASSIGN out of the undo window so the UNASSIGN below is + # recorded as a distinct event instead of being absorbed. + assign_event = models.ThreadEvent.objects.get( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + _force_past_undo_window(assign_event) + + # Then unassign + unassign_data = { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + response = api_client.post( + get_thread_event_url(thread.id), unassign_data, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + + # UserEvent ASSIGN should be deleted + assert ( + models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + def test_unassign_idempotent(self, api_client): + """Unassign someone who is not assigned returns 204 without creating ThreadEvent.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + data = { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # No ThreadEvent UNASSIGN should be created + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 0 + ) + + def test_unassign_visible_in_timeline(self, api_client): + """After UNASSIGN, GET events returns the UNASSIGN event in the timeline.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + # First assign + assign_data = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + api_client.post(get_thread_event_url(thread.id), assign_data, format="json") + + # Push the ASSIGN out of the undo window so the UNASSIGN below is not + # absorbed as an "undo". + assign_event = models.ThreadEvent.objects.get( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + _force_past_undo_window(assign_event) + + # Then unassign + unassign_data = { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + api_client.post(get_thread_event_url(thread.id), unassign_data, format="json") + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + unassign_events = [e for e in response.data if e["type"] == "unassign"] + assert len(unassign_events) == 1 + + def test_unassign_viewer_forbidden(self, api_client): + """Viewer role POST type=unassign returns 403 per D-02.""" + user, mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.VIEWER + ) + assignee = factories.UserFactory() + factories.MailboxAccessFactory(mailbox=mailbox, user=assignee) + api_client.force_authenticate(user=user) + + data = { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + response = api_client.post(get_thread_event_url(thread.id), data, format="json") + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_unassign_mixed_payload_narrows_to_active_assignees(self, api_client): + """UNASSIGN with a mix of assigned and non-assigned users only emits the active ones. + + Regression guard: the previous ``.exists()`` check let a non-assigned + user slip into the emitted UNASSIGN event as long as one targeted user + was actually assigned. + """ + user, mailbox, thread = setup_user_with_thread_access() + assigned = factories.UserFactory() + not_assigned = factories.UserFactory() + for target in (assigned, not_assigned): + factories.MailboxAccessFactory( + mailbox=mailbox, user=target, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + # Only ``assigned`` gets an ASSIGN. + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": {"assignees": [{"id": str(assigned.id), "name": "A"}]}, + }, + format="json", + ) + assign_event = models.ThreadEvent.objects.get( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + _force_past_undo_window(assign_event) + + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": { + "assignees": [ + {"id": str(assigned.id), "name": "A"}, + {"id": str(not_assigned.id), "name": "B"}, + ] + }, + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + unassign_events = list( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ) + ) + assert len(unassign_events) == 1 + emitted_ids = {a["id"] for a in unassign_events[0].data["assignees"]} + assert emitted_ids == {str(assigned.id)} + + def test_unassign_only_inactive_users_returns_204(self, api_client): + """UNASSIGN targeting only users without an active ASSIGN returns 204.""" + user, mailbox, thread = setup_user_with_thread_access() + not_assigned = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=not_assigned, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(not_assigned.id), "name": "B"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 0 + ) + + +class TestThreadEventUnassignUndoWindow: + """Test the assign/unassign "undo window" that swallows back-to-back events. + + When an UNASSIGN arrives within ``UNDO_WINDOW_SECONDS`` after an ASSIGN from + the same author for the same user, both events are elided from the + timeline: the matching assignees are stripped from the ASSIGN event (event + deleted if it becomes empty) and no UNASSIGN event is emitted. + """ + + def test_undo_within_window_removes_both_events(self, api_client): + """Assign then immediately unassign: 204, no events remain, UserEvent gone.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assert models.ThreadEvent.objects.filter(thread=thread).count() == 0 + assert ( + models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + def test_undo_different_author_does_not_apply(self, api_client): + """An UNASSIGN by a different author leaves both events in place.""" + author_a, mailbox, thread = setup_user_with_thread_access() + author_b = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=author_b, role=enums.MailboxRoleChoices.ADMIN + ) + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + + # author_a assigns + api_client.force_authenticate(user=author_a) + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + + # author_b unassigns within the window + api_client.force_authenticate(user=author_b) + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 1 + ) + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 1 + ) + + def test_undo_outside_window_does_not_apply(self, api_client): + """An UNASSIGN past the undo window follows the regular path.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + + # Push ASSIGN past the undo window + assign_event = models.ThreadEvent.objects.get( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + _force_past_undo_window(assign_event) + + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 1 + ) + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 1 + ) + + def test_undo_partial_trims_assignees(self, api_client): + """Assign [A, B], then unassign A within window: ASSIGN keeps B, no UNASSIGN event.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee_a = factories.UserFactory() + assignee_b = factories.UserFactory() + for target in (assignee_a, assignee_b): + factories.MailboxAccessFactory( + mailbox=mailbox, user=target, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": { + "assignees": [ + {"id": str(assignee_a.id), "name": "A"}, + {"id": str(assignee_b.id), "name": "B"}, + ] + }, + }, + format="json", + ) + + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee_a.id), "name": "A"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assign_events = list( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + ) + assert len(assign_events) == 1 + remaining_ids = {a["id"] for a in assign_events[0].data["assignees"]} + assert remaining_ids == {str(assignee_b.id)} + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 0 + ) + # Only B's UserEvent should survive + surviving = set( + models.UserEvent.objects.filter( + thread=thread, type=enums.UserEventTypeChoices.ASSIGN + ).values_list("user_id", flat=True) + ) + assert surviving == {assignee_b.id} + + def test_undo_then_reassign_leaves_clean_state(self, api_client): + """Assign, undo within window, then reassign: exactly one active ASSIGN.""" + user, mailbox, thread = setup_user_with_thread_access() + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + assign_payload = { + "type": "assign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + unassign_payload = { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee.id), "name": "Assignee"}]}, + } + + api_client.post(get_thread_event_url(thread.id), assign_payload, format="json") + api_client.post( + get_thread_event_url(thread.id), unassign_payload, format="json" + ) + response = api_client.post( + get_thread_event_url(thread.id), assign_payload, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ).count() + == 1 + ) + assert ( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).count() + == 0 + ) + assert ( + models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 1 + ) + + def test_undo_does_not_rewrite_history_for_inactive_user(self, api_client): + """UNASSIGN for a non-assigned user must not alter a recent ASSIGN event. + + Regression guard: the undo-window absorb used to strip any targeted + ``assignee_id`` from the recent ``ThreadEvent(ASSIGN).data`` — even + when that user no longer had an active ``UserEvent(ASSIGN)``, which + silently corrupted the thread's assignment history. + """ + user, mailbox, thread = setup_user_with_thread_access() + assignee_a = factories.UserFactory() + assignee_b = factories.UserFactory() + for target in (assignee_a, assignee_b): + factories.MailboxAccessFactory( + mailbox=mailbox, user=target, role=enums.MailboxRoleChoices.ADMIN + ) + api_client.force_authenticate(user=user) + + # Assign [A, B] then clear A's active UserEvent out-of-band (simulates + # an earlier unassign whose ThreadEvent has since been archived). + api_client.post( + get_thread_event_url(thread.id), + { + "type": "assign", + "data": { + "assignees": [ + {"id": str(assignee_a.id), "name": "A"}, + {"id": str(assignee_b.id), "name": "B"}, + ] + }, + }, + format="json", + ) + models.UserEvent.objects.filter( + user=assignee_a, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).delete() + + # UNASSIGN A within the undo window: A is no longer active, so the + # absorb path must be skipped entirely. + response = api_client.post( + get_thread_event_url(thread.id), + { + "type": "unassign", + "data": {"assignees": [{"id": str(assignee_a.id), "name": "A"}]}, + }, + format="json", + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assign_events = list( + models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.ASSIGN + ) + ) + assert len(assign_events) == 1 + preserved_ids = {a["id"] for a in assign_events[0].data["assignees"]} + assert preserved_ids == {str(assignee_a.id), str(assignee_b.id)} + # B's UserEvent must survive untouched. + assert models.UserEvent.objects.filter( + user=assignee_b, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() diff --git a/src/backend/core/tests/api/test_thread_filter_assignment.py b/src/backend/core/tests/api/test_thread_filter_assignment.py new file mode 100644 index 000000000..da96eea67 --- /dev/null +++ b/src/backend/core/tests/api/test_thread_filter_assignment.py @@ -0,0 +1,397 @@ +"""Tests for has_assigned_to_me and has_unassigned filters and stats on Thread API.""" + +from django.urls import reverse + +import pytest +from rest_framework import status + +from core import enums, factories, models + +pytestmark = pytest.mark.django_db + + +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 TestThreadFilterAssignedToMe: + """Test GET /api/v1.0/threads/?has_assigned_to_me=1 filter.""" + + def test_filter_returns_threads_assigned_to_me(self, api_client): + """Filter should return only threads with active ASSIGN UserEvent for current user.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create an active assignment on this thread for the current user + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + # Create another thread without assignment + thread_no_assign = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_no_assign, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_assigned_to_me": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread.id) in thread_ids + assert str(thread_no_assign.id) not in thread_ids + + def test_filter_excludes_threads_assigned_to_me(self, api_client): + """Filter with has_assigned_to_me=0 should return threads NOT assigned to current user.""" + user, mailbox, thread_assigned = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create an active assignment + event = factories.ThreadEventFactory(thread=thread_assigned, author=user) + factories.UserEventFactory( + user=user, + thread=thread_assigned, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + # Create another thread without assignment + thread_no_assign = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_no_assign, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_assigned_to_me": "0"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread_no_assign.id) in thread_ids + assert str(thread_assigned.id) not in thread_ids + + def test_unassigned_thread_not_shown(self, api_client): + """After UNASSIGN, the UserEvent is deleted and the thread must disappear.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + factories.ThreadEventFactory( + thread=thread, + author=user, + type=enums.ThreadEventTypeChoices.ASSIGN, + data={"assignees": [{"id": str(user.id), "name": user.full_name or ""}]}, + ) + factories.ThreadEventFactory( + thread=thread, + author=user, + type=enums.ThreadEventTypeChoices.UNASSIGN, + data={"assignees": [{"id": str(user.id), "name": user.full_name or ""}]}, + ) + + assert ( + models.UserEvent.objects.filter( + thread=thread, + user=user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_assigned_to_me": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + +class TestThreadFilterUnassigned: + """Test GET /api/v1.0/threads/?has_unassigned=1 filter.""" + + def test_filter_returns_unassigned_threads(self, api_client): + """Filter should return threads with no active ASSIGN UserEvent from any user.""" + user, mailbox, thread_unassigned = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create an assigned thread (assigned to another user) + thread_assigned = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_assigned, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread_assigned, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread_assigned, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unassigned": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread_unassigned.id) in thread_ids + assert str(thread_assigned.id) not in thread_ids + + def test_filter_returns_assigned_threads(self, api_client): + """Filter with has_unassigned=0 should return threads WITH at least one active assignment.""" + user, mailbox, thread_unassigned = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create an assigned thread + thread_assigned = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_assigned, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread_assigned, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread_assigned, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unassigned": "0"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread_assigned.id) in thread_ids + assert str(thread_unassigned.id) not in thread_ids + + +class TestThreadStatsAssignment: + """Test GET /api/v1.0/threads/stats/ for assignment stats.""" + + def test_stats_has_assigned_to_me(self, api_client): + """Stats should return correct has_assigned_to_me count.""" + user, mailbox, thread1 = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread 1: assigned to me + event1 = factories.ThreadEventFactory(thread=thread1, author=user) + factories.UserEventFactory( + user=user, + thread=thread1, + thread_event=event1, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + # Thread 2: not assigned + thread2 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread2, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_assigned_to_me", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_assigned_to_me"] == 1 + + def test_stats_has_unassigned(self, api_client): + """Stats should return correct has_unassigned count.""" + user, mailbox, _thread_unassigned = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread 2: assigned to another user + thread_assigned = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_assigned, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread_assigned, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread_assigned, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_unassigned", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_unassigned"] == 1 + + def test_stats_all_with_assigned_to_me_filter(self, api_client): + """Stats with all field and has_assigned_to_me filter should return correct total.""" + user, mailbox, thread1 = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Assign thread1 to me + event1 = factories.ThreadEventFactory(thread=thread1, author=user) + factories.UserEventFactory( + user=user, + thread=thread1, + thread_event=event1, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + # Create thread2 also assigned to me + thread2 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread2, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event2 = factories.ThreadEventFactory(thread=thread2, author=user) + factories.UserEventFactory( + user=user, + thread=thread2, + thread_event=event2, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + # Thread3 not assigned + thread3 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread3, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "all", + "has_assigned_to_me": "1", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["all"] == 2 + + +class TestThreadAssignedUsersField: + """Test the ``assigned_users`` field exposed on GET /api/v1.0/threads/.""" + + def test_empty_when_no_active_assignment(self, api_client): + """Threads with no active ASSIGN UserEvent expose an empty list.""" + user, mailbox, _ = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-list"), {"mailbox_id": str(mailbox.id)} + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["results"][0]["assigned_users"] == [] + + def test_exposes_all_current_assignees(self, api_client): + """All users with an active ASSIGN UserEvent appear in ``assigned_users``.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + assignee_a = factories.UserFactory(full_name="Alice Martin") + assignee_b = factories.UserFactory(full_name="Bob Durand") + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=assignee_a, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + factories.UserEventFactory( + user=assignee_b, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + response = api_client.get( + reverse("threads-list"), {"mailbox_id": str(mailbox.id)} + ) + + assert response.status_code == status.HTTP_200_OK + assigned = response.data["results"][0]["assigned_users"] + assert {u["id"] for u in assigned} == {str(assignee_a.id), str(assignee_b.id)} + assert {u["name"] for u in assigned} == {"Alice Martin", "Bob Durand"} + + def test_drops_unassigned_users(self, api_client): + """Users whose ASSIGN UserEvent was removed must not appear anymore.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + kept = factories.UserFactory(full_name="Kept User") + removed = factories.UserFactory(full_name="Removed User") + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=kept, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + factories.UserEventFactory( + user=removed, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + # Simulate an unassign: the corresponding ASSIGN UserEvent is deleted + # (mirrors delete_assign_user_events in core.signals). + models.UserEvent.objects.filter( + thread=thread, + user=removed, + type=enums.UserEventTypeChoices.ASSIGN, + ).delete() + + response = api_client.get( + reverse("threads-list"), {"mailbox_id": str(mailbox.id)} + ) + + assert response.status_code == status.HTTP_200_OK + assigned = response.data["results"][0]["assigned_users"] + assert [u["id"] for u in assigned] == [str(kept.id)] diff --git a/src/backend/core/tests/api/test_thread_user.py b/src/backend/core/tests/api/test_thread_user.py index 0467982a3..e7b4314de 100644 --- a/src/backend/core/tests/api/test_thread_user.py +++ b/src/backend/core/tests/api/test_thread_user.py @@ -137,7 +137,8 @@ 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.""" + """Each user object must contain id, email, full_name, custom_attributes, + and can_post_comments.""" user, _mailbox, thread = setup_user_with_thread_access() api_client.force_authenticate(user=user) @@ -150,6 +151,7 @@ def test_response_fields(self, api_client): assert "email" in user_data assert "full_name" in user_data assert "custom_attributes" in user_data + assert "can_post_comments" in user_data # Must not contain abilities assert "abilities" not in user_data @@ -433,3 +435,130 @@ def test_mailbox_access_role_does_not_filter_users(self, api_client): assert str(viewer.id) in returned_ids assert str(editor.id) in returned_ids assert str(sender.id) in returned_ids + + +class TestThreadUserListCanPostComments: + """Test the ``can_post_comments`` flag exposed per user. + + Mirrors the ``_user_can_comment_on_thread`` rule: a user can post + comments iff they have a MailboxAccess with a role in + MAILBOX_ROLES_CAN_EDIT on a mailbox that has ThreadAccess to the + thread — regardless of the ThreadAccess role (VIEWER or EDITOR). + """ + + def test_viewer_on_mailbox_cannot_post_comments(self, api_client): + """A user whose only mailbox role on thread-linked mailboxes is + VIEWER must be flagged ``can_post_comments=False``.""" + user, mailbox, thread = setup_user_with_thread_access() + + viewer = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=viewer, 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 + + by_id = {u["id"]: u for u in response.data} + assert by_id[str(viewer.id)]["can_post_comments"] is False + + @pytest.mark.parametrize( + "mailbox_role", + [ + enums.MailboxRoleChoices.EDITOR, + enums.MailboxRoleChoices.SENDER, + enums.MailboxRoleChoices.ADMIN, + ], + ) + def test_editor_level_roles_can_post_comments(self, api_client, mailbox_role): + """Any mailbox role in MAILBOX_ROLES_CAN_EDIT yields + ``can_post_comments=True``.""" + user, mailbox, thread = setup_user_with_thread_access() + + other = factories.UserFactory() + factories.MailboxAccessFactory(mailbox=mailbox, user=other, role=mailbox_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 + + by_id = {u["id"]: u for u in response.data} + assert by_id[str(other.id)]["can_post_comments"] is True + + def test_thread_viewer_with_editor_mailbox_can_post(self, api_client): + """ThreadAccess VIEWER combined with an editor-level MailboxAccess + still yields ``can_post_comments=True`` — the comment rule only + cares about the mailbox role.""" + user, _mailbox, thread = setup_user_with_thread_access() + + other_mailbox = factories.MailboxFactory() + other = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=other_mailbox, user=other, role=enums.MailboxRoleChoices.EDITOR + ) + factories.ThreadAccessFactory( + mailbox=other_mailbox, + 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 + + by_id = {u["id"]: u for u in response.data} + assert by_id[str(other.id)]["can_post_comments"] is True + + def test_mixed_roles_across_mailboxes_grants_true(self, api_client): + """A user who is VIEWER on one thread-linked mailbox and EDITOR + on another must be flagged ``can_post_comments=True``.""" + user, mailbox_a, thread = setup_user_with_thread_access() + + # Second mailbox also linked to the thread + mailbox_b = factories.MailboxFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_b, + thread=thread, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + + mixed = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox_a, user=mixed, role=enums.MailboxRoleChoices.VIEWER + ) + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=mixed, 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 + + by_id = {u["id"]: u for u in response.data} + assert by_id[str(mixed.id)]["can_post_comments"] is True + + def test_editor_role_on_unrelated_mailbox_does_not_grant(self, api_client): + """An editor-level MailboxAccess on a mailbox that has no + ThreadAccess to this thread must NOT grant ``can_post_comments``.""" + user, mailbox, thread = setup_user_with_thread_access() + + other = factories.UserFactory() + # VIEWER on the thread-linked mailbox + factories.MailboxAccessFactory( + mailbox=mailbox, user=other, role=enums.MailboxRoleChoices.VIEWER + ) + # EDITOR on a separate mailbox NOT linked to this thread + unrelated_mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=unrelated_mailbox, + user=other, + 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 + + by_id = {u["id"]: u for u in response.data} + assert by_id[str(other.id)]["can_post_comments"] is False diff --git a/src/backend/core/tests/api/test_threads_list.py b/src/backend/core/tests/api/test_threads_list.py index 69d9dd944..7509d8282 100644 --- a/src/backend/core/tests/api/test_threads_list.py +++ b/src/backend/core/tests/api/test_threads_list.py @@ -4,6 +4,8 @@ from datetime import timedelta from unittest import mock +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils import timezone @@ -22,6 +24,7 @@ ThreadAccessFactory, ThreadEventFactory, ThreadFactory, + UserEventFactory, UserFactory, ) from core.models import MailboxAccess, Thread @@ -1837,3 +1840,84 @@ def test_list_threads_events_count_distinct_per_thread(self, api_client, url): assert response.status_code == status.HTTP_200_OK payload = next(t for t in response.data["results"] if t["id"] == str(thread.id)) assert payload["events_count"] == 2 + + +class TestThreadListQueryCount: + """Regression guard for N+1 queries on the thread list endpoint. + + ``ThreadSerializer`` exposes nested fields (accesses, messages, labels, + user_role, assigned_users) that each used to trigger one or more SQL + queries per thread. The viewset now attaches matching prefetches so the + query count stays constant regardless of the number of threads listed. + """ + + @pytest.fixture + def url(self): + """Return the URL for the list endpoint.""" + return reverse("threads-list") + + def _setup(self, thread_count, with_labels=False, with_assignees=False): + """Provision a user + mailbox with ``thread_count`` threads.""" + user = UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + label = LabelFactory(mailbox=mailbox) if with_labels else None + assignee = UserFactory() if with_assignees else None + for _ in range(thread_count): + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + if label is not None: + label.threads.add(thread) + if assignee is not None: + event = ThreadEventFactory(thread=thread, author=user) + UserEventFactory( + user=assignee, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ) + return user, mailbox + + @staticmethod + def _count_queries(api_client, user, mailbox, url): + """Return the number of SQL queries emitted by the list call.""" + api_client.force_authenticate(user=user) + with CaptureQueriesContext(connection) as ctx: + response = api_client.get(url, {"mailbox_id": str(mailbox.id)}) + assert response.status_code == status.HTTP_200_OK + return len(ctx.captured_queries) + + def test_base_fields_are_prefetched(self, api_client, url): + """With only accesses/messages, query count is constant across thread counts.""" + user_1, mailbox_1 = self._setup(1) + queries_1 = self._count_queries(api_client, user_1, mailbox_1, url) + + user_5, mailbox_5 = self._setup(5) + queries_5 = self._count_queries(api_client, user_5, mailbox_5, url) + + assert queries_5 == queries_1, ( + f"Expected constant query count (N+1 regression?), " + f"got 1→{queries_1} vs 5→{queries_5}" + ) + + def test_labels_and_assignees_prefetched(self, api_client, url): + """Adding labels + assignees on every thread must not scale queries with N.""" + user_1, mailbox_1 = self._setup(1, with_labels=True, with_assignees=True) + queries_1 = self._count_queries(api_client, user_1, mailbox_1, url) + + user_5, mailbox_5 = self._setup(5, with_labels=True, with_assignees=True) + queries_5 = self._count_queries(api_client, user_5, mailbox_5, url) + + assert queries_5 == queries_1, ( + f"Expected constant query count with labels+assignees " + f"(N+1 regression on prefetch chain?), " + f"got 1→{queries_1} vs 5→{queries_5}" + ) diff --git a/src/backend/core/tests/models/test_thread_event.py b/src/backend/core/tests/models/test_thread_event.py new file mode 100644 index 000000000..6dc097873 --- /dev/null +++ b/src/backend/core/tests/models/test_thread_event.py @@ -0,0 +1,95 @@ +"""Tests for ThreadEvent model ASSIGN/UNASSIGN schema validation.""" + +import uuid + +from django.core.exceptions import ValidationError + +import pytest + +from core import enums, factories + +pytestmark = pytest.mark.django_db + + +class TestThreadEventTypeChoices: + """Test that ThreadEventTypeChoices contains ASSIGN and UNASSIGN values.""" + + def test_assign_value(self): + """ThreadEventTypeChoices.ASSIGN should equal 'assign'.""" + assert enums.ThreadEventTypeChoices.ASSIGN.value == "assign" + + def test_unassign_value(self): + """ThreadEventTypeChoices.UNASSIGN should equal 'unassign'.""" + assert enums.ThreadEventTypeChoices.UNASSIGN.value == "unassign" + + +class TestThreadEventAssignSchema: + """Test DATA_SCHEMAS validation for ASSIGN and UNASSIGN ThreadEvent types.""" + + def test_assign_with_valid_data_passes_clean(self): + """ThreadEvent type=assign with valid assignees data should pass full_clean().""" + thread = factories.ThreadFactory() + author = factories.UserFactory() + valid_uuid = str(uuid.uuid4()) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type="assign", + data={"assignees": [{"id": valid_uuid, "name": "Alice"}]}, + ) + # If we get here without error, the schema validated + assert event.id is not None + + def test_unassign_with_valid_data_passes_clean(self): + """ThreadEvent type=unassign with valid assignees data should pass full_clean().""" + thread = factories.ThreadFactory() + author = factories.UserFactory() + valid_uuid = str(uuid.uuid4()) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type="unassign", + data={"assignees": [{"id": valid_uuid, "name": "Bob"}]}, + ) + assert event.id is not None + + def test_assign_with_empty_assignees_raises_validation_error(self): + """ThreadEvent type=assign with empty assignees list should raise ValidationError.""" + thread = factories.ThreadFactory() + author = factories.UserFactory() + + with pytest.raises(ValidationError): + factories.ThreadEventFactory( + thread=thread, + author=author, + type="assign", + data={"assignees": []}, + ) + + def test_assign_with_wrong_schema_raises_validation_error(self): + """ThreadEvent type=assign with content instead of assignees should raise.""" + thread = factories.ThreadFactory() + author = factories.UserFactory() + + with pytest.raises(ValidationError): + factories.ThreadEventFactory( + thread=thread, + author=author, + type="assign", + data={"content": "hello"}, + ) + + def test_assign_with_non_uuid_id_raises_validation_error(self): + """ThreadEvent type=assign with a non-UUID id must be rejected.""" + thread = factories.ThreadFactory() + author = factories.UserFactory() + + with pytest.raises(ValidationError): + factories.ThreadEventFactory( + thread=thread, + author=author, + type="assign", + data={"assignees": [{"id": "not-uuid", "name": "X"}]}, + ) diff --git a/src/backend/core/tests/models/test_user_event.py b/src/backend/core/tests/models/test_user_event.py index 982dbcba2..aff6b7945 100644 --- a/src/backend/core/tests/models/test_user_event.py +++ b/src/backend/core/tests/models/test_user_event.py @@ -177,3 +177,128 @@ def test_user_event_bulk_create_ignore_conflicts_absorbs_duplicates(self): ).count() == 1 ) + + def test_user_event_assign_partial_unique_rejects_second_assign_on_same_thread( + self, + ): + """At most one ASSIGN UserEvent is allowed per (user, thread). + + This partial UniqueConstraint is the schema-level guarantee behind the + idempotence logic in ThreadEventViewSet.create: two concurrent ASSIGN + requests can both decide to create a UserEvent; the DB arbitrates. + The two inserts use *different* thread_event FKs, so the existing + (user, thread_event, type) constraint alone wouldn't catch them. + """ + user = factories.UserFactory() + thread = factories.ThreadFactory() + thread_event_1 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + thread_event_2 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=thread_event_1, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + with transaction.atomic(), pytest.raises(IntegrityError): + core_models.UserEvent.objects.bulk_create( + [ + core_models.UserEvent( + user=user, + thread=thread, + thread_event=thread_event_2, + type=enums.UserEventTypeChoices.ASSIGN, + ) + ] + ) + + def test_user_event_assign_bulk_create_ignore_conflicts_absorbs_race(self): + """``bulk_create(..., ignore_conflicts=True)`` must absorb ASSIGN races. + + Mirrors ``create_assign_user_events``: a second ASSIGN for the same + (user, thread) via a new ThreadEvent must be silently dropped by the + partial UniqueConstraint, leaving the original UserEvent intact. + """ + user = factories.UserFactory() + thread = factories.ThreadFactory() + thread_event_1 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + thread_event_2 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=thread_event_1, + type=enums.UserEventTypeChoices.ASSIGN, + ) + + core_models.UserEvent.objects.bulk_create( + [ + core_models.UserEvent( + user=user, + thread=thread, + thread_event=thread_event_2, + type=enums.UserEventTypeChoices.ASSIGN, + ) + ], + ignore_conflicts=True, + ) + + assigns = core_models.UserEvent.objects.filter( + user=user, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ) + assert assigns.count() == 1 + assert assigns.first().thread_event_id == thread_event_1.id + + def test_user_event_assign_partial_unique_allows_reassign_after_unassign(self): + """Re-ASSIGN after UNASSIGN must succeed. + + The partial UniqueConstraint only applies while an ASSIGN UserEvent + exists. ``delete_assign_user_events`` removes the row on UNASSIGN, so + a subsequent ASSIGN for the same (user, thread) must be accepted. + """ + user = factories.UserFactory() + thread = factories.ThreadFactory() + thread_event_1 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + thread_event_2 = factories.ThreadEventFactory( + thread=thread, + type="assign", + data={"assignees": [{"id": str(user.id), "name": "u"}]}, + ) + + event_1 = factories.UserEventFactory( + user=user, + thread=thread, + thread_event=thread_event_1, + type=enums.UserEventTypeChoices.ASSIGN, + ) + event_1.delete() + + event_2 = core_models.UserEvent.objects.create( + user=user, + thread=thread, + thread_event=thread_event_2, + type=enums.UserEventTypeChoices.ASSIGN, + ) + assert event_2.id is not None diff --git a/src/backend/core/tests/services/test_thread_events.py b/src/backend/core/tests/services/test_thread_events.py new file mode 100644 index 000000000..e050cc6e3 --- /dev/null +++ b/src/backend/core/tests/services/test_thread_events.py @@ -0,0 +1,744 @@ +"""Test the ``core.services.thread_events`` service layer. + +These tests previously lived in ``core/tests/test_signals.py`` when the +behaviour they cover was implemented via Django signals. The signals +are gone (see ``core/services/thread_events.py``); the assertions stay +the same — only the trigger changes from ``ThreadEventFactory(...)`` / +``access.delete()`` to an explicit service call. +""" +# pylint: disable=too-many-lines,missing-function-docstring + +from unittest.mock import patch + +from django.utils import timezone + +import pytest + +from core import enums, factories, models +from core.services import thread_events as thread_events_service + +pytestmark = pytest.mark.django_db + + +def _setup_thread_with_mentioned_user(): + """Create a thread with a user who has access and can be mentioned. + + Returns ``(author, mentioned_user, thread, mailbox)``. The access + chain ``mentioned_user → MailboxAccess → mailbox → ThreadAccess → + thread`` is what the MENTION validation walks. + """ + author = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory(mailbox=mailbox, user=author) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory(thread=thread, mailbox=mailbox) + mentioned_user = factories.UserFactory() + factories.MailboxAccessFactory(mailbox=mailbox, user=mentioned_user) + return author, mentioned_user, thread, mailbox + + +def _setup_thread_with_assignable_user(): + """Create a thread plus a user who has full edit rights on it. + + Both author and target_user get ADMIN MailboxAccess and the shared + ThreadAccess is EDITOR, so the assignment rule (full edit rights on + the assignee) is satisfied deterministically. + """ + author = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=author, role=enums.MailboxRoleChoices.ADMIN + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + thread=thread, + mailbox=mailbox, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + target_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=target_user, role=enums.MailboxRoleChoices.ADMIN + ) + return author, target_user, thread, mailbox + + +# --------------------------------------------------------------------------- +# sync_im_mentions +# --------------------------------------------------------------------------- + + +class TestSyncImMentions: + """``thread_events_service.sync_im_mentions`` — IM MENTION reconciliation.""" + + def test_creates_user_event_for_valid_mention(self): + """A ThreadEvent IM with a valid mention creates one UserEvent MENTION.""" + author, mentioned_user, thread, _ = _setup_thread_with_mentioned_user() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Hello @John", + "mentions": [{"id": str(mentioned_user.id), "name": "John"}], + }, + ) + + thread_events_service.sync_im_mentions(thread_event=event) + + user_events = models.UserEvent.objects.filter( + thread_event=event, user=mentioned_user, type="mention" + ) + assert user_events.count() == 1 + user_event = user_events.first() + assert user_event.read_at is None + assert user_event.thread == thread + + def test_deduplicates_mentions_within_same_event(self): + author, mentioned_user, thread, _ = _setup_thread_with_mentioned_user() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Hello @John and @John again", + "mentions": [ + {"id": str(mentioned_user.id), "name": "John"}, + {"id": str(mentioned_user.id), "name": "John"}, + ], + }, + ) + + thread_events_service.sync_im_mentions(thread_event=event) + + assert ( + models.UserEvent.objects.filter( + thread_event=event, user=mentioned_user + ).count() + == 1 + ) + + def test_multiple_events_create_separate_user_events(self): + author, mentioned_user, thread, _ = _setup_thread_with_mentioned_user() + mention_data = { + "content": "Hello @John", + "mentions": [{"id": str(mentioned_user.id), "name": "John"}], + } + event1 = factories.ThreadEventFactory( + thread=thread, author=author, data=mention_data + ) + event2 = factories.ThreadEventFactory( + thread=thread, author=author, data=mention_data + ) + + thread_events_service.sync_im_mentions(thread_event=event1) + thread_events_service.sync_im_mentions(thread_event=event2) + + user_events = models.UserEvent.objects.filter( + user=mentioned_user, thread=thread, type="mention" + ) + assert user_events.count() == 2 + assert set(user_events.values_list("thread_event_id", flat=True)) == { + event1.id, + event2.id, + } + + def test_skips_mention_without_thread_access(self): + author, _, thread, _ = _setup_thread_with_mentioned_user() + no_access_user = factories.UserFactory() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Hello @Ghost", + "mentions": [{"id": str(no_access_user.id), "name": "Ghost"}], + }, + ) + + with patch("core.services.thread_events.logger") as mock_logger: + thread_events_service.sync_im_mentions(thread_event=event) + + assert models.UserEvent.objects.filter(user=no_access_user).count() == 0 + mock_logger.warning.assert_called() + assert str(no_access_user.id) in str(mock_logger.warning.call_args) + + def test_skips_invalid_user_id(self): + author, _, thread, _ = _setup_thread_with_mentioned_user() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Hello @Ghost", + "mentions": [ + {"id": "00000000-0000-0000-0000-000000000000", "name": "Ghost"} + ], + }, + ) + initial_count = models.UserEvent.objects.count() + + with patch("core.services.thread_events.logger") as mock_logger: + thread_events_service.sync_im_mentions(thread_event=event) + + assert models.UserEvent.objects.count() == initial_count + mock_logger.warning.assert_called() + + def test_ignores_non_im_events(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type="assign", + data={"assignees": [{"id": str(target_user.id), "name": "Target"}]}, + ) + initial_count = models.UserEvent.objects.count() + + thread_events_service.sync_im_mentions(thread_event=event) + + assert models.UserEvent.objects.count() == initial_count + + def test_ignores_im_without_mentions(self): + author, _, thread, _ = _setup_thread_with_mentioned_user() + event = factories.ThreadEventFactory( + thread=thread, author=author, data={"content": "Hello everyone"} + ) + initial_count = models.UserEvent.objects.count() + + thread_events_service.sync_im_mentions(thread_event=event) + + assert models.UserEvent.objects.count() == initial_count + + def test_ignores_im_with_empty_mentions(self): + author, _, thread, _ = _setup_thread_with_mentioned_user() + event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={"content": "Hello everyone", "mentions": []}, + ) + initial_count = models.UserEvent.objects.count() + + thread_events_service.sync_im_mentions(thread_event=event) + + assert models.UserEvent.objects.count() == initial_count + + +# --------------------------------------------------------------------------- +# assign_users +# --------------------------------------------------------------------------- + + +class TestAssignUsers: + """``thread_events_service.assign_users`` — ASSIGN flow.""" + + def test_assign_creates_thread_event_and_user_event(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + + event = thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + assert event is not None + assert event.type == enums.ThreadEventTypeChoices.ASSIGN + user_events = models.UserEvent.objects.filter( + thread_event=event, + user=target_user, + type=enums.UserEventTypeChoices.ASSIGN, + ) + assert user_events.count() == 1 + assert user_events.first().read_at is None + assert user_events.first().thread == thread + + def test_assign_with_two_valid_assignees_creates_two_user_events(self): + author, target_user, thread, mailbox = _setup_thread_with_assignable_user() + target_user2 = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=target_user2, role=enums.MailboxRoleChoices.ADMIN + ) + + event = thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[ + {"id": str(target_user.id), "name": "Target1"}, + {"id": str(target_user2.id), "name": "Target2"}, + ], + ) + + assert event is not None + assert ( + models.UserEvent.objects.filter( + thread_event=event, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 2 + ) + + def test_assign_idempotent_returns_none_when_all_already_assigned(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + second = thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + assert second is None + assert ( + models.UserEvent.objects.filter( + thread=thread, + user=target_user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 1 + ) + + def test_assign_raises_when_assignee_lacks_edit_rights(self): + author, _, thread, mailbox = _setup_thread_with_assignable_user() + viewer_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=viewer_user, role=enums.MailboxRoleChoices.VIEWER + ) + + with pytest.raises(ValueError, match="editor access"): + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(viewer_user.id), "name": "Viewer"}], + ) + + def test_assign_raises_when_assignee_has_no_thread_access(self): + author, _, thread, _ = _setup_thread_with_assignable_user() + no_access_user = factories.UserFactory() + + with pytest.raises(ValueError, match="editor access"): + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(no_access_user.id), "name": "NoAccess"}], + ) + + +# --------------------------------------------------------------------------- +# unassign_users +# --------------------------------------------------------------------------- + + +class TestUnassignUsers: + """``thread_events_service.unassign_users`` — UNASSIGN flow.""" + + def test_unassign_removes_existing_assign_user_event(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + assert ( + models.UserEvent.objects.filter( + thread=thread, + user=target_user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 1 + ) + + # The undo window absorbs this UNASSIGN if the same author requests + # it within 120s. Use a different author to exercise the regular + # unassign path. + other_author = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=thread.accesses.first().mailbox, + user=other_author, + role=enums.MailboxRoleChoices.ADMIN, + ) + + event = thread_events_service.unassign_users( + thread=thread, + author=other_author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + assert event is not None + assert event.type == enums.ThreadEventTypeChoices.UNASSIGN + assert ( + models.UserEvent.objects.filter( + thread=thread, + user=target_user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + def test_unassign_returns_none_when_no_active_assign(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + + event = thread_events_service.unassign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + assert event is None + assert not models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).exists() + + def test_undo_window_absorbs_unassign_by_same_author(self): + author, target_user, thread, _ = _setup_thread_with_assignable_user() + assign_event = thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + result = thread_events_service.unassign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(target_user.id), "name": "Target"}], + ) + + # Original ASSIGN deleted, no UNASSIGN ThreadEvent emitted, no + # surviving UserEvent ASSIGN. + assert result is None + assert not models.ThreadEvent.objects.filter(id=assign_event.id).exists() + assert not models.ThreadEvent.objects.filter( + thread=thread, type=enums.ThreadEventTypeChoices.UNASSIGN + ).exists() + assert ( + models.UserEvent.objects.filter( + thread=thread, + user=target_user, + type=enums.UserEventTypeChoices.ASSIGN, + ).count() + == 0 + ) + + +# --------------------------------------------------------------------------- +# revoke_thread_access / downgrade_thread_access +# --------------------------------------------------------------------------- + + +class TestThreadAccessCleanup: + """Cleanup of ASSIGN / MENTION on ThreadAccess delete or downgrade.""" + + def _setup_assigned_user(self): + """Set up an author, an assignee, and assign the user to the thread.""" + author = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=author, role=enums.MailboxRoleChoices.ADMIN + ) + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + thread=thread, + mailbox=mailbox, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(assignee.id), "name": "Assignee"}], + ) + assert models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() + return author, assignee, thread, mailbox + + def _assert_auto_unassigned(self, thread, assignee): + assert not models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() + system_unassign = models.ThreadEvent.objects.filter( + thread=thread, + type=enums.ThreadEventTypeChoices.UNASSIGN, + author__isnull=True, + ).last() + assert system_unassign is not None + assignee_ids = [a["id"] for a in system_unassign.data["assignees"]] + assert str(assignee.id) in assignee_ids + + def test_revoke_thread_access_unassigns_users_of_mailbox(self): + _, assignee, thread, mailbox = self._setup_assigned_user() + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + # Caller deletes first; the service then runs cleanup using the + # in-memory instance. + thread_access.delete() + + thread_events_service.revoke_thread_access(thread_access=thread_access) + + self._assert_auto_unassigned(thread, assignee) + + def test_downgrade_thread_access_unassigns_users_of_mailbox(self): + _, assignee, thread, mailbox = self._setup_assigned_user() + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + # Caller is responsible for actually moving the role; the service + # only runs cleanup against the post-downgrade state. + thread_access.role = enums.ThreadAccessRoleChoices.VIEWER + thread_access.save() + + thread_events_service.downgrade_thread_access(thread_access=thread_access) + + self._assert_auto_unassigned(thread, assignee) + + def test_revoke_keeps_assignment_when_user_editor_via_other_mailbox(self): + _, assignee, thread, mailbox = self._setup_assigned_user() + other_mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=other_mailbox, + user=assignee, + role=enums.MailboxRoleChoices.ADMIN, + ) + factories.ThreadAccessFactory( + thread=thread, + mailbox=other_mailbox, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + thread_access.delete() + + thread_events_service.revoke_thread_access(thread_access=thread_access) + + assert models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() + assert not models.ThreadEvent.objects.filter( + thread=thread, + type=enums.ThreadEventTypeChoices.UNASSIGN, + author__isnull=True, + ).exists() + + +class TestMailboxAccessCleanup: + """Cleanup of ASSIGN on MailboxAccess delete or downgrade.""" + + def _setup_assigned_user(self): + author = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=author, role=enums.MailboxRoleChoices.ADMIN + ) + assignee = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=assignee, role=enums.MailboxRoleChoices.ADMIN + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + thread=thread, + mailbox=mailbox, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + thread_events_service.assign_users( + thread=thread, + author=author, + assignees_data=[{"id": str(assignee.id), "name": "Assignee"}], + ) + return author, assignee, thread, mailbox + + def test_revoke_mailbox_access_unassigns_user(self): + _, assignee, thread, mailbox = self._setup_assigned_user() + access = models.MailboxAccess.objects.get(user=assignee, mailbox=mailbox) + access.delete() + + thread_events_service.revoke_mailbox_access(mailbox_access=access) + + assert not models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() + assert models.ThreadEvent.objects.filter( + thread=thread, + type=enums.ThreadEventTypeChoices.UNASSIGN, + author__isnull=True, + ).exists() + + def test_downgrade_mailbox_access_unassigns_user(self): + _, assignee, thread, mailbox = self._setup_assigned_user() + access = models.MailboxAccess.objects.get(user=assignee, mailbox=mailbox) + access.role = enums.MailboxRoleChoices.VIEWER + access.save() + + thread_events_service.downgrade_mailbox_access(mailbox_access=access) + + assert not models.UserEvent.objects.filter( + user=assignee, + thread=thread, + type=enums.UserEventTypeChoices.ASSIGN, + ).exists() + + +# --------------------------------------------------------------------------- +# Mention cleanup on access change +# --------------------------------------------------------------------------- + + +class TestMentionCleanup: + """Cleanup of MENTION rows when a user loses access to a thread.""" + + def _setup_mentioned_user(self): + author = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=author, role=enums.MailboxRoleChoices.ADMIN + ) + mentioned_user = factories.UserFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=mentioned_user, + role=enums.MailboxRoleChoices.EDITOR, + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + thread=thread, + mailbox=mailbox, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + mention_event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Hello @John", + "mentions": [{"id": str(mentioned_user.id), "name": "John"}], + }, + ) + thread_events_service.sync_im_mentions(thread_event=mention_event) + assert models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() + return author, mentioned_user, thread, mailbox, mention_event + + def _assert_mention_cleaned(self, thread, mentioned_user): + assert not models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() + # No system ThreadEvent is created for mention cleanup — the IM + # event carrying the mention stays as historical source of truth. + assert not models.ThreadEvent.objects.filter( + thread=thread, + author__isnull=True, + ).exists() + + def test_revoke_thread_access_cleans_mentions(self): + _, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user() + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + thread_access.delete() + + thread_events_service.revoke_thread_access(thread_access=thread_access) + + self._assert_mention_cleaned(thread, mentioned_user) + + def test_revoke_mailbox_access_cleans_mentions(self): + _, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user() + access = models.MailboxAccess.objects.get(user=mentioned_user, mailbox=mailbox) + access.delete() + + thread_events_service.revoke_mailbox_access(mailbox_access=access) + + self._assert_mention_cleaned(thread, mentioned_user) + + def test_downgrade_thread_access_does_not_touch_mentions(self): + _, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user() + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + thread_access.role = enums.ThreadAccessRoleChoices.VIEWER + thread_access.save() + + thread_events_service.downgrade_thread_access(thread_access=thread_access) + + # Downgrades do not invalidate mentions: the user can still read. + assert models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() + + def test_downgrade_mailbox_access_does_not_touch_mentions(self): + _, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user() + access = models.MailboxAccess.objects.get(user=mentioned_user, mailbox=mailbox) + access.role = enums.MailboxRoleChoices.VIEWER + access.save() + + thread_events_service.downgrade_mailbox_access(mailbox_access=access) + + assert models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() + + def test_revoke_keeps_mention_when_user_reaches_thread_via_other_mailbox(self): + _, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user() + other_mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=other_mailbox, + user=mentioned_user, + role=enums.MailboxRoleChoices.VIEWER, + ) + factories.ThreadAccessFactory( + thread=thread, + mailbox=other_mailbox, + role=enums.ThreadAccessRoleChoices.VIEWER, + ) + access = models.MailboxAccess.objects.get(user=mentioned_user, mailbox=mailbox) + access.delete() + + thread_events_service.revoke_mailbox_access(mailbox_access=access) + + assert models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() + + def test_revoke_removes_read_and_unread_mentions(self): + author, mentioned_user, thread, mailbox, first_event = ( + self._setup_mentioned_user() + ) + second_event = factories.ThreadEventFactory( + thread=thread, + author=author, + data={ + "content": "Ping again @John", + "mentions": [{"id": str(mentioned_user.id), "name": "John"}], + }, + ) + thread_events_service.sync_im_mentions(thread_event=second_event) + models.UserEvent.objects.filter( + user=mentioned_user, + thread_event=first_event, + type=enums.UserEventTypeChoices.MENTION, + ).update(read_at=timezone.now()) + assert ( + models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).count() + == 2 + ) + thread_access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox) + thread_access.delete() + + thread_events_service.revoke_thread_access(thread_access=thread_access) + + assert not models.UserEvent.objects.filter( + user=mentioned_user, + thread=thread, + type=enums.UserEventTypeChoices.MENTION, + ).exists() diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 581ac6ddf..0b6f96d26 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -8,6 +8,9 @@ from contextvars import ContextVar from typing import Any +from django.core.exceptions import ValidationError + +import jsonschema from configurations import values logger = logging.getLogger(__name__) @@ -33,6 +36,20 @@ def extract_snippet(parsed_data: dict[str, Any], fallback: str = "") -> str: return fallback[:SNIPPET_MAX_LENGTH] +def validate_json_schema(value, schema, *, field): + """Validate ``value`` against ``schema`` and raise a Django ValidationError + keyed by ``field`` when it does not match. + + A ``FormatChecker`` is always supplied so JSON Schema ``format`` keywords + (e.g. ``uuid``) are actually enforced — without it they are annotation-only + and invalid values slip through silently. + """ + try: + jsonschema.validate(value, schema, format_checker=jsonschema.FormatChecker()) + except jsonschema.ValidationError as exception: + raise ValidationError({field: exception.message}) from exception + + class AbstractBatchingDeferrer: """ Base class for scoped batching of deferred actions via ContextVar. diff --git a/src/e2e/src/__tests__/thread-event.spec.ts b/src/e2e/src/__tests__/thread-event.spec.ts index a3f2cd9ed..829fa68fd 100644 --- a/src/e2e/src/__tests__/thread-event.spec.ts +++ b/src/e2e/src/__tests__/thread-event.spec.ts @@ -4,6 +4,23 @@ import { signInKeycloakIfNeeded, inboxFolderLink } from "../utils-test"; import { BrowserName } from "../types"; import { API_URL } from "../constants"; +/** + * Expand the system-events disclosure group if present. + * + * 3+ consecutive non-IM events (assign/unassign) get collapsed into a + * "{{count}} assignment changes" toggle. The inner system lines are not + * rendered until the disclosure is expanded — assertions on + * "You assigned yourself" / "You unassigned yourself" must trigger this + * first when re-runs accumulate events on the same thread. + */ +async function expandSystemEventsGroup(page: Page) { + const toggle = page.locator(".thread-event--collapsed button[aria-expanded]"); + if (!(await toggle.isVisible())) return; + if ((await toggle.getAttribute("aria-expanded")) === "false") { + await toggle.click(); + } +} + /** * Navigate to the shared mailbox and open the IM test thread. */ @@ -552,3 +569,232 @@ test.describe("Thread Events (Internal Messages)", () => { expect(updateResponse.status()).toBe(403); }); }); + +test.describe("Thread Events (Assignations)", () => { + test.beforeEach(async ({ page, browserName }) => { + await signInKeycloakIfNeeded({ + page, + username: `user.e2e.${browserName}`, + }); + }); + + test("does not show the assignees widget on a personal mailbox thread", async ({ + page, + }) => { + // Personal mailbox threads are not collaborative (single mailbox access, + // is_shared=false): the widget is gated behind useIsSharedContext and + // must not render — assignment has no meaning when only one human can + // ever see the thread. + await page.waitForLoadState("networkidle"); + + await inboxFolderLink(page).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 expect(page.locator(".assignees-widget")).toHaveCount(0); + }); + + test("self-assign and unassign via the quick-assign popover", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Reset assignment state via API to keep the test independent of any + // residue from previous runs (the suite has no per-test db:reset). + // Posting an unassign for a user not currently assigned is a no-op for + // `useAssignedUsers`, which walks events in reverse and only keeps the + // most recent decision per user. + const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i); + const threadId = threadMatch?.[1]; + expect(threadId, "thread id should be present in URL").toBeTruthy(); + const cookies = await page.context().cookies(); + const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? ""; + const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`); + expect(meResponse.ok()).toBeTruthy(); + const me = (await meResponse.json()) as { id: string; full_name: string }; + await page.request.post( + `${API_URL}/api/v1.0/threads/${threadId}/events/`, + { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + data: { + type: "unassign", + data: { assignees: [{ id: me.id, name: me.full_name }] }, + }, + }, + ); + await page.getByRole("button", { name: "Refresh" }).click(); + await page.waitForLoadState("networkidle"); + + // Empty state: trigger advertises the action verb. The aria-label flips + // to "Assigned to ..." once the widget reads at least one assignee. + const emptyTrigger = page.getByRole("button", { + name: "Assign users to this thread", + }); + await expect(emptyTrigger).toBeVisible(); + await emptyTrigger.click(); + + // Popover renders in a portal at document.body — locate globally. + const popover = page.locator(".quick-assign-popover"); + await expect(popover).toBeVisible(); + + // Sort order puts the current user first under the visible label "You". + // Multi-selection menu exposes each row as menuitemcheckbox. + const meRow = popover.getByRole("menuitemcheckbox", { name: /^You$/ }); + await expect(meRow).toHaveAttribute("aria-checked", "false"); + + await meRow.click(); + await expect(meRow).toHaveAttribute("aria-checked", "true"); + + await page.keyboard.press("Escape"); + await expect(popover).not.toBeVisible(); + + // Trigger label flips to assignee-list form — confirms the cache + // invalidation round-trip and the widget re-derived assignedUsers. + await expect( + page.getByRole("button", { name: /^Assigned to / }), + ).toBeVisible(); + + // Single discrete system line with viewer-relative phrasing — exercises + // the buildAssignmentMessage code path for author=me, assignees=[me]. + // Re-runs accumulate events on the shared thread; expand the disclosure + // group when the 3+ event threshold collapses the inline lines. + await expandSystemEventsGroup(page); + await expect( + page + .locator(".thread-event--system-line") + .filter({ hasText: "You assigned yourself" }) + .last(), + ).toBeVisible(); + + // Toggle back to assert the unassign path produces a matching system + // line and reverts the trigger to its empty-state label. Keeps the + // database state clean for re-runs without db:reset. + const assignedTrigger = page.getByRole("button", { name: /^Assigned to / }); + await assignedTrigger.click(); + await expect(popover).toBeVisible(); + + const assignedRow = popover.getByRole("menuitemcheckbox", { name: /^You$/ }); + await expect(assignedRow).toHaveAttribute("aria-checked", "true"); + await assignedRow.click(); + await expect(assignedRow).toHaveAttribute("aria-checked", "false"); + + await page.keyboard.press("Escape"); + await expect(popover).not.toBeVisible(); + + await expect( + page.getByRole("button", { name: "Assign users to this thread" }), + ).toBeVisible(); + // Within UNDO_WINDOW_SECONDS (120s, see thread_events service), an + // unassign by the same author retracts the original ASSIGN event + // instead of emitting an UNASSIGN — so the timeline shows neither a + // "You assigned yourself" nor a "You unassigned yourself" line for + // this self-assign / self-unassign pair. Assert the absence rather + // than chasing a system line the backend deliberately suppresses. + await expandSystemEventsGroup(page); + await expect( + page + .locator(".thread-event--system-line") + .filter({ hasText: "You assigned yourself" }), + ).toHaveCount(0); + await expect( + page + .locator(".thread-event--system-line") + .filter({ hasText: "You unassigned yourself" }), + ).toHaveCount(0); + }); + + test("self-assigned thread surfaces in the 'Assigned to me' folder", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Drive the assignment through the API to keep this test independent + // of the popover flow exercised above. The cookies set during sign-in + // carry both the session id and the CSRF token DRF expects on POST. + const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i); + const threadId = threadMatch?.[1]; + expect(threadId, "thread id should be present in URL").toBeTruthy(); + + const cookies = await page.context().cookies(); + const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? ""; + + const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`); + expect(meResponse.ok()).toBeTruthy(); + const me = (await meResponse.json()) as { id: string; full_name: string }; + + const assignResponse = await page.request.post( + `${API_URL}/api/v1.0/threads/${threadId}/events/`, + { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + data: { + type: "assign", + data: { assignees: [{ id: me.id, name: me.full_name }] }, + }, + }, + ); + expect(assignResponse.ok()).toBeTruthy(); + + // Force a refetch so the sidebar stats and thread list pick up the + // new assignment without relying on a full page reload (which would + // hit the static-export hydration race that bounces shared-mailbox + // sessions back to the personal mailbox). + await page.getByRole("button", { name: "Refresh" }).click(); + await page.waitForLoadState("networkidle"); + + // The "Assigned to me" sub-folder lives under Inbox. Its visible text + // includes the leading "person" Material icon ligature, so the + // hasText filter cannot anchor on the start — substring matching + // mirrors the convention used by inboxFolderLink. + // + // The expanded state is initialized once at MailboxList mount based on + // the *first* selected mailbox: if the session started on a personal + // identity mailbox (is_shared=false), Inbox stays collapsed even after + // switching to a shared mailbox. Force-expand here so the sub-folder + // is reachable regardless of the initial mount state. + const expandInbox = page.getByRole("button", { name: "Expand Inbox" }); + if (await expandInbox.isVisible()) { + await expandInbox.click(); + } + await page + .locator("nav.mailbox-list .mailbox__item") + .filter({ hasText: "Assigned to me" }) + .first() + .click(); + await page.waitForLoadState("networkidle"); + + await expect( + page.getByRole("link", { name: "Shared inbox thread for IM" }).first(), + ).toBeVisible(); + + // Cleanup: drop the assignment so the test is rerun-safe even when + // db:reset is skipped between iterations. + const unassignResponse = await page.request.post( + `${API_URL}/api/v1.0/threads/${threadId}/events/`, + { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + data: { + type: "unassign", + data: { assignees: [{ id: me.id, name: me.full_name }] }, + }, + }, + ); + expect(unassignResponse.ok()).toBeTruthy(); + }); +}); diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 49ee3fcbc..2bb0a0756 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -1,4 +1,30 @@ { + "{{assignees}} was unassigned_one": "{{assignees}} was unassigned", + "{{assignees}} was unassigned_other": "{{assignees}} were unassigned", + "{{author}} assigned {{assignees}}_one": "{{author}} assigned {{assignees}}", + "{{author}} assigned {{assignees}}_other": "{{author}} assigned {{assignees}}", + "{{author}} assigned themself": "{{author}} assigned themself", + "{{author}} assigned themself and {{assignees}}_one": "{{author}} assigned themself and {{assignees}}", + "{{author}} assigned themself and {{assignees}}_other": "{{author}} assigned themself and {{assignees}}", + "{{author}} assigned you": "{{author}} assigned you", + "{{author}} assigned you and {{assignees}}_one": "{{author}} assigned you and {{assignees}}", + "{{author}} assigned you and {{assignees}}_other": "{{author}} assigned you and {{assignees}}", + "{{author}} assigned you and themself": "{{author}} assigned you and themself", + "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} assigned you, themself and {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} assigned you, themself and {{assignees}}", + "{{author}} unassigned {{assignees}}_one": "{{author}} unassigned {{assignees}}", + "{{author}} unassigned {{assignees}}_other": "{{author}} unassigned {{assignees}}", + "{{author}} unassigned themself": "{{author}} unassigned themself", + "{{author}} unassigned themself and {{assignees}}_one": "{{author}} unassigned themself and {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_other": "{{author}} unassigned themself and {{assignees}}", + "{{author}} unassigned you": "{{author}} unassigned you", + "{{author}} unassigned you and {{assignees}}_one": "{{author}} unassigned you and {{assignees}}", + "{{author}} unassigned you and {{assignees}}_other": "{{author}} unassigned you and {{assignees}}", + "{{author}} unassigned you and themself": "{{author}} unassigned you and themself", + "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} unassigned you, themself and {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} unassigned you, themself and {{assignees}}", + "{{count}} assignment changes_one": "{{count}} assignment change", + "{{count}} assignment changes_other": "{{count}} assignment changes", "{{count}} attachments_one": "{{count}} attachment", "{{count}} attachments_other": "{{count}} attachments", "{{count}} attendees_one": "{{count}} attendee", @@ -11,6 +37,8 @@ "{{count}} messages_other": "{{count}} messages", "{{count}} messages are now starred._one": "The message is now starred.", "{{count}} messages are now starred._other": "{{count}} messages are now starred.", + "{{count}} messages assigned to you_one": "{{count}} message assigned to you", + "{{count}} messages assigned to you_other": "{{count}} messages assigned to you", "{{count}} messages have been archived._one": "The message has been archived.", "{{count}} messages have been archived._other": "{{count}} messages have been archived.", "{{count}} messages have been deleted._one": "The message has been deleted.", @@ -33,6 +61,8 @@ "{{count}} months ago_other": "{{count}} months ago", "{{count}} occurrences_one": "{{count}} occurrence", "{{count}} occurrences_other": "{{count}} occurrences", + "{{count}} of which are shared_one": ", {{count}} of which is shared", + "{{count}} of which are shared_other": ", {{count}} of which are shared", "{{count}} out of {{total}} messages are now starred._one": "{{count}} out of {{total}} message is now starred.", "{{count}} out of {{total}} messages are now starred._other": "{{count}} out of {{total}} messages are now starred.", "{{count}} out of {{total}} messages have been archived._one": "{{count}} out of {{total}} message has been archived.", @@ -51,6 +81,8 @@ "{{count}} out of {{total}} threads have been reported as spam._other": "{{count}} out of {{total}} threads have been reported as spam.", "{{count}} results_one": "{{count}} result", "{{count}} results_other": "{{count}} results", + "{{count}} results assigned to you_one": "{{count}} result assigned to you", + "{{count}} results assigned to you_other": "{{count}} results assigned to you", "{{count}} results mentioning you_one": "{{count}} result mentioning you", "{{count}} results mentioning you_other": "{{count}} results mentioning you", "{{count}} selected threads_one": "{{count}} selected thread", @@ -98,6 +130,8 @@ "{{count}} years ago_one": "{{count}} year ago", "{{count}} years ago_other": "{{count}} years ago", "{{date}} at {{time}}": "{{date}} at {{time}}", + "{{name}} assigned to this thread": "{{name}} assigned to this thread", + "{{name}} unassigned from this thread": "{{name}} unassigned from this thread", "{{progress}}% imported": "{{progress}}% imported", "2 columns": "2 columns", "Abort upload": "Abort upload", @@ -148,8 +182,16 @@ "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?", + "Assign": "Assign", "Assign this label": "Assign this label", "Assign this label and archive": "Assign this label and archive", + "Assign to...": "Assign to...", + "Assign users to this thread": "Assign users to this thread", + "Assigned to {{count}} people_one": "Assigned to {{count}} person", + "Assigned to {{count}} people_other": "Assigned to {{count}} people", + "Assigned to {{names}}": "Assigned to {{names}}", + "Assigned to me": "Assigned to me", + "Assigned to this thread": "Assigned to this thread", "At least one recipient is required.": "At least one recipient is required.", "Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Attachment failed to be saved into your {{driveAppName}}'s workspace.", "Attachment saved into your {{driveAppName}}'s workspace.": "Attachment saved into your {{driveAppName}}'s workspace.", @@ -335,6 +377,7 @@ "General": "General", "Generate an API key to send messages programmatically from your applications.": "Generate an API key to send messages programmatically from your applications.", "Generating summary...": "Generating summary...", + "Grant editor access to the thread?": "Grant editor access to the thread?", "Help center & Support": "Help center & Support", "How to allow IMAP connections from your account {{name}}?": "How to allow IMAP connections from your account {{name}}?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.", @@ -398,6 +441,7 @@ "Loading tags...": "Loading tags...", "Loading template...": "Loading template...", "Loading templates...": "Loading templates...", + "Loading users": "Loading users", "Loading variables...": "Loading variables...", "Loading…": "Loading…", "Logout": "Logout", @@ -451,6 +495,7 @@ "No event found in calendar invite": "No event found in calendar invite", "No integration found": "No integration found", "No mailbox": "No mailbox", + "No matching users": "No matching users", "No message could be archived.": "No message could be archived.", "No message could be deleted.": "No message could be deleted.", "No message could be reported as spam.": "No message could be reported as spam.", @@ -467,6 +512,8 @@ "No thread could be starred.": "No thread could be starred.", "No threads": "No threads", "No threads match the active filters": "No threads match the active filters", + "OK": "OK", + "Older": "Older", "On going": "On going", "Open {{driveAppName}} preview": "Open {{driveAppName}} preview", "Open filters": "Open filters", @@ -486,12 +533,14 @@ "Print": "Print", "Read": "Read", "Read state": "Read state", + "Read-only": "Read-only", "Recurring": "Recurring", "Recurring weekly": "Recurring weekly", "Redirection": "Redirection", "Refresh": "Refresh", "Refresh summary": "Refresh summary", "Remove": "Remove", + "Remove {{displayName}}": "Remove {{displayName}}", "Remove access?": "Remove access?", "Remove report": "Remove report", "Remove spam report": "Remove spam report", @@ -512,8 +561,11 @@ "Scheduled": "Scheduled", "Search": "Search", "Search a label": "Search a label", + "Search a mailbox to share this thread with": "Search a mailbox to share this thread with", "Search a tag": "Search a tag", "Search in messages...": "Search in messages...", + "Search results": "Search results", + "Search users": "Search users", "See members of this thread ({{count}} members)_one": "See members of this thread ({{count}} members)", "See members of this thread ({{count}} members)_other": "See members of this thread ({{count}} members)", "Select a parent label": "Select a parent label", @@ -529,10 +581,13 @@ "Sent": "Sent", "Sent by {{name}}": "Sent by {{name}}", "Settings": "Settings", - "Share access": "Share access", + "Share and assign the thread": "Share and assign the thread", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.", "Share the new credentials to the user.": "Share the new credentials to the user.", + "Share the thread": "Share the thread", "Share your feedback here...": "Share your feedback here...", + "Shared between {{count}} mailboxes_one": "Shared between {{count}} mailbox", + "Shared between {{count}} mailboxes_other": "Shared between {{count}} mailboxes", "Shared mailbox": "Shared mailbox", "Show": "Show", "Show {{count}} more_one": "Show {{count}} more", @@ -592,6 +647,7 @@ "The email {{email}} is invalid.": "The email {{email}} is invalid.", "The email address is invalid.": "The email address is invalid.", "The forced signature will be the only one usable for new messages.": "The forced signature will be the only one usable for new messages.", + "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.", "The message could not be sent.": "The message could not be sent.", "The message could not be sent. Please try again later.": "The message could not be sent. Please try again later.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "The personal mailbox <1>{{mailboxAddress}} has been created successfully.", @@ -602,11 +658,11 @@ "These tags will be automatically applied to every incoming message from the widget.": "These tags will be automatically applied to every incoming message from the widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "This action cannot be undone and the user will need the new password to access its mailbox.", "This contact's identity could not be verified. Proceed with caution.": "This contact's identity could not be verified. Proceed with caution.", - "This message failed sender authentication and is likely a forgery. Do not trust it.": "This message failed sender authentication and is likely a forgery. Do not trust it.", "This description will be used by the AI to automatically assign this label to your messages.": "This description will be used by the AI to automatically assign this label to your messages.", "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.", "This event has been cancelled": "This event has been cancelled", "This is the only admin of this mailbox, you cannot therefore modify its access.": "This is the only admin of this mailbox, you cannot therefore modify its access.", + "This message failed sender authentication and is likely a forgery. Do not trust it.": "This message failed sender authentication and is likely a forgery. Do not trust it.", "This message has {{count}} attachments_one": "This message has one attachment", "This message has {{count}} attachments_other": "This message has {{count}} attachments", "This message has a draft": "This message has a draft", @@ -619,6 +675,7 @@ "This signature is forced": "This signature is forced", "This thread has been reported as spam.": "This thread has been reported as spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "This thread has been reported as spam. For your security, downloading attachments has been disabled.", + "This week": "This week", "This will move this message and all following messages to a new thread. Continue?": "This will move this message and all following messages to a new thread. Continue?", "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.", "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.", @@ -639,6 +696,8 @@ "Unable to copy credentials.": "Unable to copy credentials.", "Unable to copy to clipboard.": "Unable to copy to clipboard.", "Unarchive": "Unarchive", + "Unassign": "Unassign", + "Unassigned": "Unassigned", "Undelete": "Undelete", "Undo": "Undo", "Unfold message": "Unfold message", @@ -672,8 +731,15 @@ "Yearly": "Yearly", "Yesterday": "Yesterday", "You": "You", + "You and {{assignees}} were unassigned_one": "You and {{assignees}} were unassigned", + "You and {{assignees}} were unassigned_other": "You and {{assignees}} were unassigned", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.", "You are the last editor of this thread, you cannot therefore modify your access.": "You are the last editor of this thread, you cannot therefore modify your access.", + "You assigned {{assignees}}_one": "You assigned {{assignees}}", + "You assigned {{assignees}}_other": "You assigned {{assignees}}", + "You assigned {{assignees}} and yourself_one": "You assigned {{assignees}} and yourself", + "You assigned {{assignees}} and yourself_other": "You assigned {{assignees}} and yourself", + "You assigned yourself": "You assigned yourself", "You can close this window and continue using the app.": "You can close this window and continue using the app.", "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.", "You can safely retry the import — messages already imported will not be duplicated.": "You can safely retry the import — messages already imported will not be duplicated.", @@ -686,6 +752,12 @@ "You left the thread": "You left the thread", "You may not have sufficient permissions for all selected threads.": "You may not have sufficient permissions for all selected threads.", "You must confirm this statement.": "You must confirm this statement.", + "You unassigned {{assignees}}_one": "You unassigned {{assignees}}", + "You unassigned {{assignees}}_other": "You unassigned {{assignees}}", + "You unassigned {{assignees}} and yourself_one": "You unassigned {{assignees}} and yourself", + "You unassigned {{assignees}} and yourself_other": "You unassigned {{assignees}} and yourself", + "You unassigned yourself": "You unassigned yourself", + "You were unassigned": "You were unassigned", "Your email...": "Your email...", "Your messages have been imported successfully!": "Your messages have been imported successfully!", "Your session has expired. Please log in again.": "Your session has expired. Please log in again." diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 8d3ea6448..8c16f702a 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -1,4 +1,40 @@ { + "{{assignees}} was unassigned_one": "{{assignees}} a été désassigné", + "{{assignees}} was unassigned_many": "{{assignees}} ont été désassignés", + "{{assignees}} was unassigned_other": "{{assignees}} ont été désassignés", + "{{author}} assigned {{assignees}}_one": "{{author}} a assigné {{assignees}}", + "{{author}} assigned {{assignees}}_many": "{{author}} a assigné {{assignees}}", + "{{author}} assigned {{assignees}}_other": "{{author}} a assigné {{assignees}}", + "{{author}} assigned themself": "{{author}} s'est assigné·e", + "{{author}} assigned themself and {{assignees}}_one": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned themself and {{assignees}}_many": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned themself and {{assignees}}_other": "{{author}} s'est assigné·e ainsi que {{assignees}}", + "{{author}} assigned you": "{{author}} vous a assigné·e", + "{{author}} assigned you and {{assignees}}_one": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you and {{assignees}}_many": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you and {{assignees}}_other": "{{author}} vous a assigné·e ainsi que {{assignees}}", + "{{author}} assigned you and themself": "{{author}} vous a assigné·e ainsi que lui-même", + "{{author}} assigned you, themself and {{assignees}}_one": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_many": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} assigned you, themself and {{assignees}}_other": "{{author}} vous a assigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned {{assignees}}_one": "{{author}} a désassigné {{assignees}}", + "{{author}} unassigned {{assignees}}_many": "{{author}} a désassigné {{assignees}}", + "{{author}} unassigned {{assignees}}_other": "{{author}} a désassigné {{assignees}}", + "{{author}} unassigned themself": "{{author}} s'est désassigné·e", + "{{author}} unassigned themself and {{assignees}}_one": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_many": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned themself and {{assignees}}_other": "{{author}} s'est désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you": "{{author}} vous a désassigné·e", + "{{author}} unassigned you and {{assignees}}_one": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you and {{assignees}}_many": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you and {{assignees}}_other": "{{author}} vous a désassigné·e ainsi que {{assignees}}", + "{{author}} unassigned you and themself": "{{author}} vous a désassigné·e ainsi que lui-même", + "{{author}} unassigned you, themself and {{assignees}}_one": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_many": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{author}} unassigned you, themself and {{assignees}}_other": "{{author}} vous a désassigné·e, lui-même ainsi que {{assignees}}", + "{{count}} assignment changes_one": "{{count}} changement d'assignation", + "{{count}} assignment changes_many": "{{count}} changements d'assignation", + "{{count}} assignment changes_other": "{{count}} changements d'assignation", "{{count}} attachments_one": "{{count}} pièce jointe", "{{count}} attachments_many": "{{count}} pièces jointes", "{{count}} attachments_other": "{{count}} pièces jointes", @@ -17,6 +53,9 @@ "{{count}} messages are now starred._one": "{{count}} message est maintenant marqué pour suivi.", "{{count}} messages are now starred._many": "{{count}} messages sont maintenant marqués pour suivi.", "{{count}} messages are now starred._other": "{{count}} messages sont maintenant marqués pour suivi.", + "{{count}} messages assigned to you_one": "{{count}} message qui vous est assigné", + "{{count}} messages assigned to you_many": "{{count}} messages qui vous sont assignés", + "{{count}} messages assigned to you_other": "{{count}} messages qui vous sont assignés", "{{count}} messages have been archived._one": "Le message a été archivé.", "{{count}} messages have been archived._many": "{{count}} messages ont été archivés.", "{{count}} messages have been archived._other": "{{count}} messages ont été archivés.", @@ -50,6 +89,9 @@ "{{count}} occurrences_one": "{{count}} événement", "{{count}} occurrences_many": "{{count}} événements", "{{count}} occurrences_other": "{{count}} événements", + "{{count}} of which are shared_one": " dont {{count}} partagée", + "{{count}} of which are shared_many": " dont {{count}} partagées", + "{{count}} of which are shared_other": " dont {{count}} partagées", "{{count}} out of {{total}} messages are now starred._one": "{{count}} message sur {{total}} est maintenant suivi.", "{{count}} out of {{total}} messages are now starred._many": "{{count}} messages sur {{total}} sont maintenant suivis.", "{{count}} out of {{total}} messages are now starred._other": "{{count}} messages sur {{total}} sont maintenant suivis.", @@ -77,6 +119,9 @@ "{{count}} results_one": "{{count}} résultat", "{{count}} results_many": "{{count}} résultats", "{{count}} results_other": "{{count}} résultats", + "{{count}} results assigned to you_one": "{{count}} résultat qui vous est assigné", + "{{count}} results assigned to you_many": "{{count}} résultats qui vous sont assignés", + "{{count}} results assigned to you_other": "{{count}} résultats qui vous sont assignés", "{{count}} results mentioning you_one": "{{count}} résultat vous mentionnant", "{{count}} results mentioning you_many": "{{count}} résultats vous mentionnant", "{{count}} results mentioning you_other": "{{count}} résultats vous mentionnant", @@ -147,6 +192,8 @@ "{{count}} years ago_many": "il y a {{count}} ans", "{{count}} years ago_other": "il y a {{count}} ans", "{{date}} at {{time}}": "{{date}} à {{time}}", + "{{name}} assigned to this thread": "{{name}} assigné à cette conversation", + "{{name}} unassigned from this thread": "{{name}} désassigné de cette conversation", "{{progress}}% imported": "{{progress}}% importés", "2 columns": "2 colonnes", "Abort upload": "Annuler le téléversement", @@ -198,8 +245,17 @@ "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 ?", + "Assign": "Assigner", "Assign this label": "Assigner ce libellé", "Assign this label and archive": "Assigner ce libellé et archiver", + "Assign to...": "Assigner à…", + "Assign users to this thread": "Assigner des utilisateurs à ce thread", + "Assigned to {{count}} people_one": "Assigné à {{count}} personne", + "Assigned to {{count}} people_many": "Assigné à {{count}} personnes", + "Assigned to {{count}} people_other": "Assigné à {{count}} personnes", + "Assigned to {{names}}": "Assigné à {{names}}", + "Assigned to me": "Assigné à moi", + "Assigned to this thread": "Assigné à cette conversation", "At least one recipient is required.": "Il faut au moins un destinataire.", "Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Impossible de sauvegarder la pièce jointe dans votre espace de travail {{driveAppName}}.", "Attachment saved into your {{driveAppName}}'s workspace.": "Pièce jointe sauvegardée dans votre espace de travail {{driveAppName}}.", @@ -389,6 +445,7 @@ "General": "Général", "Generate an API key to send messages programmatically from your applications.": "Générez une clé API pour envoyer des messages de façon programmatique depuis vos applications.", "Generating summary...": "Génération du résumé en cours...", + "Grant editor access to the thread?": "Accorder l'accès en édition à la conversation ?", "Help center & Support": "Centre d'aide et Support", "How to allow IMAP connections from your account {{name}}?": "Comment autoriser les connexions IMAP depuis votre compte {{name}} ?", "I confirm that this address corresponds to the real identity of a colleague, and I commit to deactivating it when their position ends.": "Je confirme que cette adresse correspond à l'identité d'une personne physique travaillant avec moi, et m'engage à la désactiver quand son poste prendra fin.", @@ -455,6 +512,7 @@ "Loading tags...": "Chargement des tags...", "Loading template...": "Chargement du modèle...", "Loading templates...": "Chargement des modèles...", + "Loading users": "Chargement des utilisateurs", "Loading variables...": "Chargement des variables...", "Loading…": "Chargement…", "Logout": "Déconnexion", @@ -509,6 +567,7 @@ "No event found in calendar invite": "Aucun événement trouvé dans l'invitation calendrier", "No integration found": "Aucune intégration trouvée", "No mailbox": "Aucune boîte aux lettres", + "No matching users": "Aucun utilisateur correspondant", "No message could be archived.": "Aucun message n'a pu être archivé.", "No message could be deleted.": "Aucun message n'a pu être supprimé.", "No message could be reported as spam.": "Aucun message n'a pu être signalé comme spam.", @@ -525,6 +584,8 @@ "No thread could be starred.": "Aucune conversation n'a pu être suivie.", "No threads": "Aucune conversation", "No threads match the active filters": "Aucune conversation ne correspond aux filtres actifs", + "OK": "OK", + "Older": "Plus ancien", "On going": "En cours", "Open {{driveAppName}} preview": "Ouvrir l'aperçu dans {{driveAppName}}", "Open filters": "Ouvrir les filtres", @@ -544,12 +605,14 @@ "Print": "Imprimer", "Read": "Lu", "Read state": "État de lecture", + "Read-only": "Lecture seule", "Recurring": "Récurrent", "Recurring weekly": "Hebdomadaire récurrent", "Redirection": "Redirection", "Refresh": "Actualiser", "Refresh summary": "Actualiser le résumé", "Remove": "Supprimer", + "Remove {{displayName}}": "Supprimer {{displayName}}", "Remove access?": "Retirer l'accès ?", "Remove report": "Annuler le signalement", "Remove spam report": "Annuler le signalement spam", @@ -570,8 +633,11 @@ "Scheduled": "Planifiée", "Search": "Rechercher", "Search a label": "Rechercher un libellé", + "Search a mailbox to share this thread with": "Rechercher une boîte à qui partager cette conversation", "Search a tag": "Rechercher un libellé", "Search in messages...": "Rechercher dans vos messages...", + "Search results": "Résultat de la recherche", + "Search users": "Rechercher des utilisateurs", "See members of this thread ({{count}} members)_one": "Voir les membres de cette conversation ({{count}} membre)", "See members of this thread ({{count}} members)_many": "Voir les membres de cette conversation ({{count}} membres)", "See members of this thread ({{count}} members)_other": "Voir les membres de cette conversation ({{count}} membres)", @@ -588,10 +654,14 @@ "Sent": "Envoyés", "Sent by {{name}}": "Envoyé par {{name}}", "Settings": "Paramètres", - "Share access": "Partager l'accès", + "Share and assign the thread": "Partager et assigner la conversation", "Share the credentials of this mailbox with its user. You must transfer them securely, preferably physically.": "Transmettez les identifiants de cette boîte mail à son utilisateur de façon sécurisée, de préférence physiquement.", "Share the new credentials to the user.": "Transmettez les nouveaux identifiants à l'utilisateur.", + "Share the thread": "Partager la conversation", "Share your feedback here...": "Saisir votre message...", + "Shared between {{count}} mailboxes_one": "Partagé entre {{count}} boîte aux lettres", + "Shared between {{count}} mailboxes_many": "Partagé entre {{count}} boîtes aux lettres", + "Shared between {{count}} mailboxes_other": "Partagé entre {{count}} boîtes aux lettres", "Shared mailbox": "Boîte partagée", "Show": "Afficher", "Show {{count}} more_one": "Afficher {{count}} de plus", @@ -653,6 +723,7 @@ "The email {{email}} is invalid.": "Le courriel {{email}} est invalide.", "The email address is invalid.": "L'adresse email est invalide.", "The forced signature will be the only one usable for new messages.": "La signature forcée sera la seule utilisable pour les nouveaux messages.", + "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.": "La boîte « {{mailbox}} » n'a actuellement que les droits en lecture sur cette conversation. Pour y assigner {{user}}, les droits en édition doivent être accordés à cette boîte.", "The message could not be sent.": "Le message n'a pas pu être envoyé.", "The message could not be sent. Please try again later.": "Le message n'a pas pu être envoyé. Veuillez réessayer plus tard.", "The personal mailbox {{mailboxAddress}} has been created successfully.": "L'adresse personnelle {{mailboxAddress}} a été créée avec succès.", @@ -663,11 +734,11 @@ "These tags will be automatically applied to every incoming message from the widget.": "Ces libellés seront automatiquement appliqués à chaque message entrant provenant du widget.", "This action cannot be undone and the user will need the new password to access its mailbox.": "Cette action est irréversible et l'utilisateur aura besoin du nouveau mot de passe pour accéder à sa boîte aux lettres.", "This contact's identity could not be verified. Proceed with caution.": "L'identité de ce contact n'a pas pu être vérifiée. Faites attention.", - "This message failed sender authentication and is likely a forgery. Do not trust it.": "L'authentification de l'expéditeur a échoué pour ce message, qui est probablement frauduleux. Ne lui faites pas confiance.", "This description will be used by the AI to automatically assign this label to your messages.": "Cette description sera utilisée par l'IA pour assigner automatiquement cette étiquette à vos messages.", "This email prefix is not allowed for personal mailboxes. Please choose a different prefix.": "Ce préfixe d'adresse n'est pas autorisé pour les boîtes aux lettres personnelles. Veuillez choisir un autre préfixe.", "This event has been cancelled": "Cet événement a été annulé", "This is the only admin of this mailbox, you cannot therefore modify its access.": "C'est le seul administrateur de cette boîte aux lettres, vous ne pouvez donc pas modifier son accès.", + "This message failed sender authentication and is likely a forgery. Do not trust it.": "L'authentification de l'expéditeur a échoué pour ce message, qui est probablement frauduleux. Ne lui faites pas confiance.", "This message has {{count}} attachments_one": "Ce message a une pièce jointe", "This message has {{count}} attachments_many": "Ce message a {{count}} pièces jointes", "This message has {{count}} attachments_other": "Ce message a {{count}} pièces jointes", @@ -681,6 +752,7 @@ "This signature is forced": "Cette signature est forcée", "This thread has been reported as spam.": "Cette conversation a été signalée comme spam.", "This thread has been reported as spam. For your security, downloading attachments has been disabled.": "Cette conversation a été signalée comme spam. Pour votre sécurité, le téléchargement des pièces jointes a été désactivé.", + "This week": "Cette semaine", "This will move this message and all following messages to a new thread. Continue?": "Cela déplacera ce message et tous les messages suivants dans une nouvelle conversation. Continuer ?", "Those message templates are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces modèles de message sont liés à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.", "Those signatures are linked to the mailbox \"{{mailbox}}\". In case of a shared mailbox, all other mailbox users will be able to use them.": "Ces signatures sont liées à la boîte aux lettres \"{{mailbox}}\". Dans le cas d'une boîte aux lettres partagée, tous les autres utilisateurs de la boîte aux lettres pourront les utiliser.", @@ -701,6 +773,8 @@ "Unable to copy credentials.": "Impossible de copier les identifiants.", "Unable to copy to clipboard.": "Impossible de copier dans le presse-papiers.", "Unarchive": "Désarchiver", + "Unassign": "Désassigner", + "Unassigned": "Non assigné", "Undelete": "Restaurer", "Undo": "Annuler", "Unfold message": "Développer le message", @@ -734,8 +808,18 @@ "Yearly": "Annuel", "Yesterday": "Hier", "You": "Vous", + "You and {{assignees}} were unassigned_one": "Vous et {{assignees}} avez été désassigné·e·s", + "You and {{assignees}} were unassigned_many": "Vous et {{assignees}} avez été désassigné·e·s", + "You and {{assignees}} were unassigned_other": "Vous et {{assignees}} avez été désassigné·e·s", "You and all users with access to the mailbox \"{{mailboxName}}\" will no longer see this thread.": "Vous et tous les utilisateurs avec un accès à la boîte « {{mailboxName}} » ne pourront plus voir cette conversation.", "You are the last editor of this thread, you cannot therefore modify your access.": "Vous êtes le dernier éditeur de cette conversation, vous ne pouvez donc pas modifier votre accès.", + "You assigned {{assignees}}_one": "Vous avez assigné {{assignees}}", + "You assigned {{assignees}}_many": "Vous avez assigné {{assignees}}", + "You assigned {{assignees}}_other": "Vous avez assigné {{assignees}}", + "You assigned {{assignees}} and yourself_one": "Vous avez assigné {{assignees}} et vous-même", + "You assigned {{assignees}} and yourself_many": "Vous avez assigné {{assignees}} et vous-même", + "You assigned {{assignees}} and yourself_other": "Vous avez assigné {{assignees}} et vous-même", + "You assigned yourself": "Vous vous êtes assigné·e", "You can close this window and continue using the app.": "Vous pouvez fermer cette fenêtre et continuer à utiliser l'application.", "You can now inform the person that their mailbox is ready to be used and communicate the instructions for authentication.": "Vous pouvez désormais prévenir la personne que sa boîte aux lettres est prête à être utilisée et lui communiquer les instructions pour s'authentifier.", "You can safely retry the import — messages already imported will not be duplicated.": "Vous pouvez relancer l'import en toute sécurité — les messages déjà importés ne seront pas dupliqués.", @@ -746,9 +830,17 @@ "You have {{count}} recipients, which exceeds the maximum of {{max}} recipients per message. The message cannot be sent until you reduce the number of recipients._other": "Vous avez {{count}} destinataires, ce qui dépasse le maximum de {{max}} destinataires autorisés par message. Le message ne peut pas être envoyé tant que vous n'avez pas réduit le nombre de destinataires.", "You have aborted the upload.": "Vous avez annulé le téléversement.", "You have unsaved changes. Are you sure you want to close?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir fermer ?", - "You left the thread": "Vous avez quitté le fil", + "You left the thread": "Vous avez quitté la conversation", "You may not have sufficient permissions for all selected threads.": "Vous n'avez peut-être pas les droits suffisants sur toutes les conversations sélectionnées.", "You must confirm this statement.": "Vous devez confirmer cette déclaration.", + "You unassigned {{assignees}}_one": "Vous avez désassigné {{assignees}}", + "You unassigned {{assignees}}_many": "Vous avez désassigné {{assignees}}", + "You unassigned {{assignees}}_other": "Vous avez désassigné {{assignees}}", + "You unassigned {{assignees}} and yourself_one": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned {{assignees}} and yourself_many": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned {{assignees}} and yourself_other": "Vous avez désassigné {{assignees}} et vous-même", + "You unassigned yourself": "Vous vous êtes désassigné·e", + "You were unassigned": "Vous avez été désassigné·e", "Your email...": "Renseigner votre email...", "Your messages have been imported successfully!": "Vos messages ont été importés avec succès !", "Your session has expired. Please log in again.": "Votre session a expiré. Veuillez vous reconnecter." diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index 6a693e720..c661f0df7 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -105,7 +105,6 @@ export * from "./paginated_drive_item_response"; export * from "./paginated_mail_domain_admin_list"; export * from "./paginated_mailbox_access_read_list"; export * from "./paginated_mailbox_admin_list"; -export * from "./paginated_thread_access_list"; export * from "./paginated_thread_list"; export * from "./partial_drive_item"; export * from "./patched_channel_request"; @@ -116,8 +115,6 @@ 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"; @@ -143,13 +140,19 @@ 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_assignees_data"; +export * from "./thread_event_assignees_data_request"; +export * from "./thread_event_data"; +export * from "./thread_event_data_request"; +export * from "./thread_event_im_data"; +export * from "./thread_event_im_data_request"; 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_event_user"; +export * from "./thread_event_user_request"; export * from "./thread_label"; +export * from "./thread_mentionable_user"; +export * from "./thread_mentionable_user_custom_attributes"; export * from "./thread_split_request_request"; export * from "./threads_accesses_create_params"; export * from "./threads_accesses_destroy_params"; diff --git a/src/frontend/src/features/api/gen/models/mailbox.ts b/src/frontend/src/features/api/gen/models/mailbox.ts index ccfa4ffa2..d8c433f00 100644 --- a/src/frontend/src/features/api/gen/models/mailbox.ts +++ b/src/frontend/src/features/api/gen/models/mailbox.ts @@ -17,6 +17,12 @@ export interface Mailbox { readonly email: string; /** Whether this mailbox identifies a person (i.e. is not an alias or a group) */ readonly is_identity: boolean; + /** Return True if the mailbox is shared (non-identity or has more than one access). + +Drives mailbox-level UI gating for collaboration features (assignment +sub-folders, mention folder) that have no purpose in a mono-user +identity mailbox. */ + readonly is_shared: boolean; readonly role: MailboxRoleChoices; /** Return the number of threads with unread messages in the mailbox. */ readonly count_unread_threads: number; diff --git a/src/frontend/src/features/api/gen/models/mailbox_light.ts b/src/frontend/src/features/api/gen/models/mailbox_light.ts index 1a8c8b133..311300f2e 100644 --- a/src/frontend/src/features/api/gen/models/mailbox_light.ts +++ b/src/frontend/src/features/api/gen/models/mailbox_light.ts @@ -14,4 +14,6 @@ export interface MailboxLight { readonly id: string; readonly email: string; readonly name: string; + /** Whether this mailbox identifies a person (i.e. is not an alias or a group) */ + readonly is_identity: boolean; } diff --git a/src/frontend/src/features/api/gen/models/paginated_thread_access_list.ts b/src/frontend/src/features/api/gen/models/paginated_thread_access_list.ts deleted file mode 100644 index bb5e5d2a7..000000000 --- a/src/frontend/src/features/api/gen/models/paginated_thread_access_list.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 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 { ThreadAccess } from "./thread_access"; - -export interface PaginatedThreadAccessList { - count: number; - /** @nullable */ - next?: string | null; - /** @nullable */ - previous?: string | null; - results: ThreadAccess[]; -} 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 index 1542e35ca..7839b0360 100644 --- 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 @@ -6,7 +6,7 @@ * 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"; +import type { ThreadEventDataRequest } from "./thread_event_data_request"; /** * Serialize thread event information. @@ -18,5 +18,5 @@ export interface PatchedThreadEventRequest { * @nullable */ message?: string | null; - data?: PatchedThreadEventRequestDataOneOf; + data?: ThreadEventDataRequest; } 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 deleted file mode 100644 index 97dff0906..000000000 --- a/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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 deleted file mode 100644 index 583f5e3be..000000000 --- a/src/frontend/src/features/api/gen/models/patched_thread_event_request_data_one_of_mentions_item.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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.ts b/src/frontend/src/features/api/gen/models/thread.ts index 68c0b855d..327b71034 100644 --- a/src/frontend/src/features/api/gen/models/thread.ts +++ b/src/frontend/src/features/api/gen/models/thread.ts @@ -9,6 +9,7 @@ import type { ThreadAccessRoleChoices } from "./thread_access_role_choices"; import type { ThreadAccessDetail } from "./thread_access_detail"; import type { ThreadLabel } from "./thread_label"; import type { ThreadAbilities } from "./thread_abilities"; +import type { ThreadEventUser } from "./thread_event_user"; /** * Serialize threads. @@ -73,4 +74,5 @@ export interface Thread { readonly summary: string; readonly events_count: number; readonly abilities: ThreadAbilities; + readonly assigned_users: readonly ThreadEventUser[]; } diff --git a/src/frontend/src/features/api/gen/models/thread_access.ts b/src/frontend/src/features/api/gen/models/thread_access.ts index 279acec79..352036730 100644 --- a/src/frontend/src/features/api/gen/models/thread_access.ts +++ b/src/frontend/src/features/api/gen/models/thread_access.ts @@ -6,6 +6,7 @@ * OpenAPI spec version: 1.0.0 (v1.0) */ import type { ThreadAccessRoleChoices } from "./thread_access_role_choices"; +import type { UserWithoutAbilities } from "./user_without_abilities"; /** * Serialize thread access information. @@ -22,4 +23,5 @@ export interface ThreadAccess { readonly created_at: string; /** date and time at which a record was last updated */ readonly updated_at: string; + readonly users: readonly UserWithoutAbilities[]; } diff --git a/src/frontend/src/features/api/gen/models/thread_event.ts b/src/frontend/src/features/api/gen/models/thread_event.ts index 96bd86b3e..612133881 100644 --- a/src/frontend/src/features/api/gen/models/thread_event.ts +++ b/src/frontend/src/features/api/gen/models/thread_event.ts @@ -7,7 +7,7 @@ */ import type { ThreadEventTypeEnum } from "./thread_event_type_enum"; import type { UserWithoutAbilities } from "./user_without_abilities"; -import type { ThreadEventDataOneOf } from "./thread_event_data_one_of"; +import type { ThreadEventData } from "./thread_event_data"; /** * Serialize thread event information. @@ -29,7 +29,7 @@ export interface ThreadEvent { */ message?: string | null; readonly author: UserWithoutAbilities; - data: ThreadEventDataOneOf; + data: ThreadEventData; readonly has_unread_mention: boolean; readonly is_editable: boolean; /** date and time at which a record was created */ diff --git a/src/frontend/src/features/api/gen/models/thread_event_assignees_data.ts b/src/frontend/src/features/api/gen/models/thread_event_assignees_data.ts new file mode 100644 index 000000000..5f6de9c78 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_assignees_data.ts @@ -0,0 +1,23 @@ +/** + * 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 { ThreadEventUser } from "./thread_event_user"; + +/** + * OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events. + +Both event types share the exact same payload shape, so a single serializer +(and thus a single generated TypeScript type) covers them. + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventAssigneesData { + assignees: ThreadEventUser[]; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_assignees_data_request.ts b/src/frontend/src/features/api/gen/models/thread_event_assignees_data_request.ts new file mode 100644 index 000000000..11b2ea450 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_assignees_data_request.ts @@ -0,0 +1,23 @@ +/** + * 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 { ThreadEventUserRequest } from "./thread_event_user_request"; + +/** + * OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``ASSIGN`` and ``UNASSIGN`` events. + +Both event types share the exact same payload shape, so a single serializer +(and thus a single generated TypeScript type) covers them. + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventAssigneesDataRequest { + assignees: ThreadEventUserRequest[]; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_data.ts b/src/frontend/src/features/api/gen/models/thread_event_data.ts new file mode 100644 index 000000000..aa58858e2 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_data.ts @@ -0,0 +1,11 @@ +/** + * 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 { ThreadEventIMData } from "./thread_event_im_data"; +import type { ThreadEventAssigneesData } from "./thread_event_assignees_data"; + +export type ThreadEventData = ThreadEventIMData | ThreadEventAssigneesData; 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 deleted file mode 100644 index b79d0d69c..000000000 --- a/src/frontend/src/features/api/gen/models/thread_event_data_one_of.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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 deleted file mode 100644 index 455bf8ba7..000000000 --- a/src/frontend/src/features/api/gen/models/thread_event_data_one_of_mentions_item.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 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_data_request.ts b/src/frontend/src/features/api/gen/models/thread_event_data_request.ts new file mode 100644 index 000000000..4a5ba6229 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_data_request.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 { ThreadEventIMDataRequest } from "./thread_event_im_data_request"; +import type { ThreadEventAssigneesDataRequest } from "./thread_event_assignees_data_request"; + +export type ThreadEventDataRequest = + | ThreadEventIMDataRequest + | ThreadEventAssigneesDataRequest; diff --git a/src/frontend/src/features/api/gen/models/thread_event_im_data.ts b/src/frontend/src/features/api/gen/models/thread_event_im_data.ts new file mode 100644 index 000000000..1583d2647 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_im_data.ts @@ -0,0 +1,21 @@ +/** + * 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 { ThreadEventUser } from "./thread_event_user"; + +/** + * OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events. + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventIMData { + content: string; + mentions?: ThreadEventUser[]; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_im_data_request.ts b/src/frontend/src/features/api/gen/models/thread_event_im_data_request.ts new file mode 100644 index 000000000..5d5e6f2e5 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_im_data_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 { ThreadEventUserRequest } from "./thread_event_user_request"; + +/** + * OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events. + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventIMDataRequest { + /** @minLength 1 */ + content: string; + mentions?: ThreadEventUserRequest[]; +} 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 index 747cef3a5..c05e1336e 100644 --- a/src/frontend/src/features/api/gen/models/thread_event_request.ts +++ b/src/frontend/src/features/api/gen/models/thread_event_request.ts @@ -6,7 +6,7 @@ * 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"; +import type { ThreadEventDataRequest } from "./thread_event_data_request"; /** * Serialize thread event information. @@ -18,5 +18,5 @@ export interface ThreadEventRequest { * @nullable */ message?: string | null; - data: ThreadEventRequestDataOneOf; + data: ThreadEventDataRequest; } 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 deleted file mode 100644 index 37d6b2474..000000000 --- a/src/frontend/src/features/api/gen/models/thread_event_request_data_one_of.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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_type_enum.ts b/src/frontend/src/features/api/gen/models/thread_event_type_enum.ts index 09a595cb6..ce742a930 100644 --- 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 @@ -8,6 +8,8 @@ /** * * `im` - Instant message + * `assign` - Assign + * `unassign` - Unassign */ export type ThreadEventTypeEnum = (typeof ThreadEventTypeEnum)[keyof typeof ThreadEventTypeEnum]; @@ -15,4 +17,6 @@ export type ThreadEventTypeEnum = // eslint-disable-next-line @typescript-eslint/no-redeclare export const ThreadEventTypeEnum = { im: "im", + assign: "assign", + unassign: "unassign", } as const; diff --git a/src/frontend/src/features/api/gen/models/thread_event_user.ts b/src/frontend/src/features/api/gen/models/thread_event_user.ts new file mode 100644 index 000000000..b4798750b --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_user.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * OpenAPI-only serializer: describes a single user inside +an ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events) + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventUser { + id: string; + name: string; +} diff --git a/src/frontend/src/features/api/gen/models/thread_event_user_request.ts b/src/frontend/src/features/api/gen/models/thread_event_user_request.ts new file mode 100644 index 000000000..6bca0071c --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_event_user_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) + */ + +/** + * OpenAPI-only serializer: describes a single user inside +an ThreadEvent.data payload. (used for ``IM`` and ``ASSIGNEES`` events) + +Not used for runtime validation (handled by +``ThreadEvent.validate_data()`` against ``ThreadEvent.DATA_SCHEMAS``); +exists solely to produce a named component in the OpenAPI schema consumed +by the generated frontend client. + */ +export interface ThreadEventUserRequest { + id: string; + /** @minLength 1 */ + name: string; +} diff --git a/src/frontend/src/features/api/gen/models/thread_mentionable_user.ts b/src/frontend/src/features/api/gen/models/thread_mentionable_user.ts new file mode 100644 index 000000000..520cdcd40 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_mentionable_user.ts @@ -0,0 +1,23 @@ +/** + * 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 { ThreadMentionableUserCustomAttributes } from "./thread_mentionable_user_custom_attributes"; + +/** + * User listed in a thread's mention suggestions, with comment capability flag. + */ +export interface ThreadMentionableUser { + /** primary key for the record as UUID */ + readonly id: string; + /** @nullable */ + readonly email: string | null; + /** @nullable */ + readonly full_name: string | null; + /** Get custom attributes for the instance. */ + readonly custom_attributes: ThreadMentionableUserCustomAttributes; + readonly can_post_comments: boolean; +} 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_mentionable_user_custom_attributes.ts similarity index 53% rename from src/frontend/src/features/api/gen/models/thread_event_request_data_one_of_mentions_item.ts rename to src/frontend/src/features/api/gen/models/thread_mentionable_user_custom_attributes.ts index a1b791bdb..aa67e098c 100644 --- 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_mentionable_user_custom_attributes.ts @@ -6,7 +6,7 @@ * OpenAPI spec version: 1.0.0 (v1.0) */ -export type ThreadEventRequestDataOneOfMentionsItem = { - id: string; - name: string; -}; +/** + * Get custom attributes for the instance. + */ +export type ThreadMentionableUserCustomAttributes = { [key: string]: unknown }; diff --git a/src/frontend/src/features/api/gen/models/threads_accesses_list_params.ts b/src/frontend/src/features/api/gen/models/threads_accesses_list_params.ts index 4569e5374..09e9e4332 100644 --- a/src/frontend/src/features/api/gen/models/threads_accesses_list_params.ts +++ b/src/frontend/src/features/api/gen/models/threads_accesses_list_params.ts @@ -11,8 +11,4 @@ export type ThreadsAccessesListParams = { * Filter thread accesses by mailbox ID. */ mailbox_id?: string; - /** - * A page number within the paginated result set. - */ - page?: number; }; diff --git a/src/frontend/src/features/api/gen/models/threads_list_params.ts b/src/frontend/src/features/api/gen/models/threads_list_params.ts index e83864791..84d70721a 100644 --- a/src/frontend/src/features/api/gen/models/threads_list_params.ts +++ b/src/frontend/src/features/api/gen/models/threads_list_params.ts @@ -15,6 +15,10 @@ export type ThreadsListParams = { * Filter threads that have archived (1=true, 0=false). */ has_archived?: number; + /** + * Filter threads assigned to the current user (1=true, 0=false). + */ + has_assigned_to_me?: number; /** * Filter threads with attachments (1=true, 0=false). */ @@ -47,6 +51,10 @@ export type ThreadsListParams = { * Filter threads that have trashed messages (1=true, 0=false). */ has_trashed?: number; + /** + * Filter threads with no active assignment from any user (1=true, 0=false). + */ + has_unassigned?: number; /** * Filter threads with unread messages (1=true, 0=false). Requires mailbox_id. */ diff --git a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts index 8b14cb742..7516f5997 100644 --- a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts +++ b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts @@ -12,6 +12,10 @@ export type ThreadsStatsRetrieveParams = { * Filter threads that are archived (1=true, 0=false). */ has_archived?: number; + /** + * Filter threads assigned to the current user (1=true, 0=false). + */ + has_assigned_to_me?: number; /** * Filter threads with attachments (1=true, 0=false). */ @@ -40,6 +44,10 @@ export type ThreadsStatsRetrieveParams = { * Filter threads that are trashed (1=true, 0=false). */ has_trashed?: number; + /** + * Filter threads with no active assignment from any user (1=true, 0=false). + */ + has_unassigned?: number; /** * Filter threads with unread mentions for the current user (1=true, 0=false). */ @@ -60,7 +68,7 @@ export type ThreadsStatsRetrieveParams = { * Comma-separated list of fields to aggregate. Special values: 'all' (count all threads), 'all_unread' (count all unread threads). Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived, - has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention. + has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention, has_assigned_to_me, has_unassigned. Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread. Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread' */ diff --git a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts index 0502031db..0ab09aa80 100644 --- a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts +++ b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts @@ -13,8 +13,10 @@ export type ThreadsStatsRetrieveStatsFields = export const ThreadsStatsRetrieveStatsFields = { all: "all", all_unread: "all_unread", + has_assigned_to_me: "has_assigned_to_me", has_delivery_failed: "has_delivery_failed", has_delivery_pending: "has_delivery_pending", has_mention: "has_mention", + has_unassigned: "has_unassigned", has_unread_mention: "has_unread_mention", } as const; diff --git a/src/frontend/src/features/api/gen/thread-access/thread-access.ts b/src/frontend/src/features/api/gen/thread-access/thread-access.ts index 2951ffa67..4b381b692 100644 --- a/src/frontend/src/features/api/gen/thread-access/thread-access.ts +++ b/src/frontend/src/features/api/gen/thread-access/thread-access.ts @@ -22,7 +22,6 @@ import type { } from "@tanstack/react-query"; import type { - PaginatedThreadAccessList, PatchedThreadAccessRequest, ThreadAccess, ThreadAccessRequest, @@ -42,7 +41,7 @@ type SecondParameter unknown> = Parameters[1]; * ViewSet for ThreadAccess model. */ export type threadsAccessesListResponse200 = { - data: PaginatedThreadAccessList; + data: ThreadAccess[]; status: 200; }; 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 index 69bb77a68..8d374a543 100644 --- a/src/frontend/src/features/api/gen/thread-events/thread-events.ts +++ b/src/frontend/src/features/api/gen/thread-events/thread-events.ts @@ -215,7 +215,15 @@ export function useThreadsEventsList< } /** - * ViewSet for ThreadEvent model. + * Create a ThreadEvent. + +For ASSIGN/UNASSIGN, delegates to the service layer which owns the +idempotence rules, edit-rights validation and the undo window. +For IM, persists the event via the serializer and then re-syncs +MENTION rows. + +Returns 204 when the service decides nothing was new (every +assignee already assigned, full UNASSIGN absorbed by undo, …). */ export type threadsEventsCreateResponse201 = { data: ThreadEvent; 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 index 085aab2fa..fa72f9301 100644 --- a/src/frontend/src/features/api/gen/thread-users/thread-users.ts +++ b/src/frontend/src/features/api/gen/thread-users/thread-users.ts @@ -18,7 +18,7 @@ import type { UseQueryResult, } from "@tanstack/react-query"; -import type { UserWithoutAbilities } from ".././models"; +import type { ThreadMentionableUser } from ".././models"; import { fetchAPI } from "../../fetch-api"; import type { ErrorType } from "../../fetch-api"; @@ -29,7 +29,7 @@ type SecondParameter unknown> = Parameters[1]; * List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess). */ export type threadsUsersListResponse200 = { - data: UserWithoutAbilities[]; + data: ThreadMentionableUser[]; status: 200; }; diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx index 82d96e335..fb576ac49 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/index.tsx @@ -1,4 +1,4 @@ -import { ThreadsStatsRetrieve200, ThreadsStatsRetrieveStatsFields, useThreadsStatsRetrieve } from "@/features/api/gen" +import { Mailbox, ThreadsStatsRetrieve200, ThreadsStatsRetrieveStatsFields, useThreadsStatsRetrieve } from "@/features/api/gen" import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox" import clsx from "clsx" import Link from "next/link" @@ -17,6 +17,14 @@ import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import { Tooltip } from "@gouvfr-lasuite/cunningham-react" import { EXPANDED_FOLDERS_KEY } from "@/features/config/constants" +type FolderVisibilityContext = { + mailbox: Mailbox; + folderCount: number; + folderStats: ThreadsStatsRetrieve200; +}; + +type FolderVisibilityRule = (ctx: FolderVisibilityContext) => boolean; + // @TODO: replace with real data when folder will be ready type Folder = { id: string; @@ -25,10 +33,33 @@ type Folder = { filter?: Record; showStats: boolean; searchable?: boolean; - conditional?: boolean; + isVisible?: FolderVisibilityRule; children?: Folder[]; } +// Hidden when empty — used for transient queues like the outbox that should +// disappear when nothing is in flight. +const visibleIfNonEmpty: FolderVisibilityRule = ({ folderCount }) => folderCount > 0; + +// Hidden in mono-user identity mailboxes — used for collaboration entries +// (e.g. "Unassigned") whose semantics require multiple humans on the box. +const visibleIfSharedMailbox: FolderVisibilityRule = ({ mailbox }) => mailbox.is_shared; + +// Hidden in mono-user identity mailboxes unless the folder already has +// content — used for entries (e.g. "Assigned to me") that only matter in +// collaborative contexts but should remain reachable when content exists +// (delegation history, ex-shared mailboxes). +const visibleIfSharedOrNonEmpty: FolderVisibilityRule = ({ mailbox, folderCount }) => + mailbox.is_shared || folderCount > 0; + +// Same intent as visibleIfSharedOrNonEmpty but anchored on the existence +// of any mention (read or unread). The badge counter still reflects the +// unread count; visibility persists as long as the user has ever been +// mentioned, even after the mentions are read. +const visibleIfSharedOrHasMention: FolderVisibilityRule = ({ mailbox, folderStats }) => + mailbox.is_shared + || (folderStats?.[ThreadsStatsRetrieveStatsFields.has_mention] ?? 0) > 0; + export const MAILBOX_FOLDERS = () => [ { id: "inbox", @@ -68,11 +99,36 @@ export const MAILBOX_FOLDERS = () => [ icon: "alternate_email", searchable: false, showStats: true, + isVisible: visibleIfSharedOrHasMention, filter: { has_active: "1", has_mention: "1", }, }, + { + id: "assigned_to_me", + name: i18n.t("Assigned to me"), + icon: "person", + searchable: false, + showStats: true, + isVisible: visibleIfSharedOrNonEmpty, + filter: { + has_assigned_to_me: "1", + has_active: "1", + }, + }, + { + id: "unassigned", + name: i18n.t("Unassigned"), + icon: "person_off", + searchable: false, + showStats: true, + isVisible: visibleIfSharedMailbox, + filter: { + has_unassigned: "1", + has_active: "1", + }, + }, ], }, { @@ -90,7 +146,7 @@ export const MAILBOX_FOLDERS = () => [ name: i18n.t("Outbox"), icon: "schedule_send", searchable: false, - conditional: true, + isVisible: visibleIfNonEmpty, showStats: true, filter: { has_sender: "1", @@ -185,10 +241,14 @@ export const findRootFolder = (predicate: (folder: Folder) => boolean): Folder | }; export const MailboxList = () => { + const { selectedMailbox } = useMailboxContext(); const [expandedFolders, setExpandedFolders] = useState>(() => { - if (typeof window === 'undefined') return { 'inbox': true }; + // Solo identity mailboxes start with Inbox collapsed: they have no + // collaboration sub-folders to surface so the tree stays compact. + const defaultState = { inbox: selectedMailbox?.is_shared ?? true }; + if (typeof window === 'undefined') return defaultState; const savedState = localStorage.getItem(EXPANDED_FOLDERS_KEY); - if (savedState === null) return { 'inbox': true }; + if (savedState === null) return defaultState; return JSON.parse(savedState) as Record; }); @@ -277,13 +337,25 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand, if (folder.id === 'drafts') return ThreadsStatsRetrieveStatsFields.all; if (folder.id === 'outbox') return ThreadsStatsRetrieveStatsFields.all; if (folder.id === 'mentioned') return ThreadsStatsRetrieveStatsFields.has_unread_mention; + if (folder.id === 'assigned_to_me') return ThreadsStatsRetrieveStatsFields.all; + if (folder.id === 'unassigned') return ThreadsStatsRetrieveStatsFields.all; return ThreadsStatsRetrieveStatsFields.all_unread; }, [folder.id]); + const requestedStatsFields = useMemo(() => { + if (folder.id === "outbox") { + return combineStatsFields(stats_fields, ThreadsStatsRetrieveStatsFields.has_delivery_failed); + } + // The Mentioned folder needs `has_mention` (any mention, read or + // unread) to drive sidebar visibility, while the badge keeps showing + // `has_unread_mention`. + if (folder.id === "mentioned") { + return combineStatsFields(stats_fields, ThreadsStatsRetrieveStatsFields.has_mention); + } + return stats_fields; + }, [folder.id, stats_fields]); const { data } = useThreadsStatsRetrieve({ mailbox_id: selectedMailbox?.id, - stats_fields: folder.id === "outbox" - ? combineStatsFields(stats_fields, ThreadsStatsRetrieveStatsFields.has_delivery_failed) - : stats_fields, + stats_fields: requestedStatsFields, ...folder.filter }, { query: { @@ -439,7 +511,11 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand, } }; - if (folder.conditional && folderCount === 0) { + if (folder.isVisible && selectedMailbox && !folder.isVisible({ + mailbox: selectedMailbox, + folderCount, + folderStats, + })) { return null; } diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss index 5dff6386a..7790ccec9 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss @@ -179,6 +179,7 @@ .thread-item__column--badges { gap: var(--c--globals--spacings--4xs); font-size: var(--c--globals--font--sizes--t); + align-items: center; } .thread-item__labels { diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx index edfcf8276..101ffda69 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx @@ -10,8 +10,9 @@ import { ThreadItemSenders } from "./thread-item-senders" import { Badge } from "@/features/ui/components/badge" import { ThreadDragPreview } from "./thread-drag-preview" import { PORTALS } from "@/features/config/constants" -import { Checkbox } from "@gouvfr-lasuite/cunningham-react" +import { Checkbox, Tooltip } from "@gouvfr-lasuite/cunningham-react" import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit" +import { AssigneesAvatarGroup } from "@/features/ui/components/assignees-avatar-group" import { LabelBadge } from "@/features/ui/components/label-badge" import { useLayoutDragContext } from "@/features/layouts/components/layout-context" import ViewHelper from "@/features/utils/view-helper" @@ -281,6 +282,25 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre /> )} + {thread.assigned_users.length > 0 && ( + u.name).join(', '), + })} + > + u.name).join(', '), + })} + > + + + + )}
diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx index d5026a894..48ad32a5e 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx @@ -47,6 +47,7 @@ export const ThreadPanelFilter = () => { has_unread: t("Unread"), has_starred: t("Starred"), has_mention: t("Mentioned"), + has_assigned_to_me: t("Assigned to me"), }), [t], ); diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx index b0eef4d11..3f4be7f01 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx @@ -133,6 +133,9 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is if (activeFilters.has_starred) { return t('{{count}} starred results', { count: threads?.count, defaultValue_one: '{{count}} starred result' }); } + if (activeFilters.has_assigned_to_me) { + return t('{{count}} results assigned to you', { count: threads?.count, defaultValue_one: '{{count}} result assigned to you' }); + } return t('{{count}} results', { count: threads?.count, defaultValue_one: '{{count}} result' }); } else { @@ -157,6 +160,9 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is if (activeFilters.has_starred) { return t('{{count}} starred messages', { count: threads?.count, defaultValue_one: '{{count}} starred message' }); } + if (activeFilters.has_assigned_to_me) { + return t('{{count}} messages assigned to you', { count: threads?.count, defaultValue_one: '{{count}} message assigned to you' }); + } return t('{{count}} messages', { count: threads?.count, defaultValue_one: '{{count}} message' }); } }, [activeFilters, isSearch, threads?.count, t]); diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts index 68e6367a4..d95515175 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts @@ -6,6 +6,7 @@ export const THREAD_PANEL_FILTER_PARAMS = [ "has_unread", "has_starred", "has_mention", + "has_assigned_to_me", ] as const; export type FilterType = (typeof THREAD_PANEL_FILTER_PARAMS)[number]; 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 b08728182..98c1d10b7 100644 --- a/src/frontend/src/features/layouts/components/thread-view/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/_index.scss @@ -103,24 +103,28 @@ padding-block: var(--c--globals--spacings--b) var(--c--globals--spacings--base); background: linear-gradient(to top, var(--c--globals--colors--black-000), var(--c--contextuals--background--surface--tertiary) 15px, var(--c--contextuals--background--surface--tertiary) 100%); z-index: 10; + container-type: inline-size; } .thread-view__header__top { display: flow-root; - .thread-action-bar { + .thread-action-bar__container { float: right; width: auto; margin-left: var(--c--globals--spacings--base); margin-bottom: var(--c--globals--spacings--4xs); } - @media screen and (max-width: breakpoint(xs)) { + // Switch to a stacked layout before the title is squeezed down to a few + // orphan letters on its first line. Using a container query (not a media + // query) keeps the trigger in sync with the resizable thread panel width. + @container (max-width: 35rem) { display: flex; flex-direction: column; gap: var(--c--globals--spacings--xs); - .thread-action-bar { + .thread-action-bar__container { float: none; align-self: flex-end; margin-left: 0; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/_index.scss new file mode 100644 index 000000000..d3d087e9f --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/_index.scss @@ -0,0 +1,17 @@ +.assignees-widget { + display: inline-flex; + align-items: center; + padding: 2px 6px; + background: none; + border: 1px solid transparent; + border-radius: var(--c--globals--border-radius--sm); + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +// Wraps Tooltip + Button so react-aria-components Popover has a stable +// DOM node to anchor against (Cunningham's Button ref doesn't always +// reach the underlying element through the Tooltip clone). +.assignees-widget__trigger-wrapper { + display: inline-flex; +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/index.tsx new file mode 100644 index 000000000..d395ce2b2 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/index.tsx @@ -0,0 +1,106 @@ +import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType } from "@gouvfr-lasuite/ui-kit"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAssignedUsers } from "@/features/message/use-assigned-users"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import useAbility, { Abilities } from "@/hooks/use-ability"; +import { useIsSharedContext } from "@/hooks/use-is-shared-context"; +import { AssigneesAvatarGroup } from "@/features/ui/components/assignees-avatar-group"; +import { QuickAssignPopover } from "./quick-assign-popover"; + +type AssigneesWidgetProps = { + /** + * Click handler used only on the read-only path (no manage rights). + * When the user can manage thread access, the widget owns its own + * popover and ignores this callback. + */ + onClick?: () => void; +}; + +/** + * Surfaces the current assignees on the thread and exposes the quick + * assignment popover for users with manage rights. The trigger is shown: + * - never when the thread is not in a shared context (no other mailboxes + * to assign to); + * - on the read-only path, only when at least one user is assigned (just + * the avatars + tooltip — clicking falls back to `onClick`); + * - on the manage path, always (avatars when any, "person_add" icon + * otherwise) — click opens the QuickAssignPopover. + */ +export const AssigneesWidget = ({ onClick }: AssigneesWidgetProps) => { + const { t } = useTranslation(); + const { selectedMailbox, selectedThread } = useMailboxContext(); + const isSharedContext = useIsSharedContext(); + const assignedUsers = useAssignedUsers(); + const canManageThreadAccess = useAbility( + Abilities.CAN_MANAGE_THREAD_ACCESS, + [selectedMailbox!, selectedThread!], + ); + const triggerRef = useRef(null); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + if (!isSharedContext) return null; + + const assignedTooltip = t('Assigned to {{names}}', { + names: assignedUsers.map((u) => u.name).join(', '), + }); + + if (!canManageThreadAccess) { + if (assignedUsers.length === 0) return null; + return ( + + + + ); + } + + const tooltipContent = assignedUsers.length === 0 + ? t('Assign users to this thread') + : assignedTooltip; + + return ( + <> + + + + + + {selectedThread?.id && ( + + )} + + ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/_index.scss new file mode 100644 index 000000000..6409d7840 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/_index.scss @@ -0,0 +1,137 @@ +// react-aria-components emits its own data attributes on the rendered +// elements ([data-focused], [data-selected], [data-disabled], +// [data-focus-visible]). The selectors below target those instead of +// :hover/:focus so the visual states match what the keyboard user +// actually feels (e.g. arrow-key "virtual focus" inside the Menu). + +.quick-assign-popover { + display: flex; + flex-direction: column; + width: 280px; + max-height: 360px; + background-color: var(--c--contextuals--background--surface--secondary); + border: 1px solid var(--c--contextuals--border--surface--primary); + border-radius: var(--c--globals--spacings--3xs, 4px); + box-shadow: 0 4px 16px 0 var(--c--contextuals--background--semantic--contextual--primary-hover); + overflow: hidden; + outline: none; +} + +.quick-assign-popover__body { + display: flex; + flex-direction: column; + flex: 1; + // Without min-height:0 a flex item refuses to shrink below the + // intrinsic height of its content, so the inner Menu would push + // past the Dialog's max-height and the overflow:auto would never + // engage — meaning the list could not be scrolled. + min-height: 0; + overflow: hidden; +} + +.quick-assign-popover__search { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base); + border-bottom: 1px solid var(--c--contextuals--border--surface--primary); + + // Brand-colored ring around the whole field when the inner Input has + // visible keyboard focus. Matches WCAG 2.4.7. + &:focus-within { + box-shadow: inset 0 0 0 2px var(--c--contextuals--border--semantic--brand--primary); + } +} + +.quick-assign-popover__search-icon { + color: var(--c--contextuals--text--surface--tertiary); + flex-shrink: 0; +} + +.quick-assign-popover__search-input { + flex: 1; + border: none; + background: none; + outline: none; + font-size: var(--c--globals--font--sizes--sm); + color: var(--c--contextuals--text--surface--primary); + min-width: 0; + + &::placeholder { + color: var(--c--contextuals--text--surface--tertiary); + } +} + +.quick-assign-popover__list { + list-style: none; + margin: 0; + /* padding: var(--c--globals--spacings--2xs); */ + overflow-y: auto; + flex: 1; + min-height: 0; + outline: none; +} + +.quick-assign-popover__status { + display: flex; + align-items: center; + justify-content: center; + gap: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--sm); + color: var(--c--contextuals--text--surface--tertiary); + font-size: var(--c--globals--font--sizes--sm); +} + +.quick-assign-popover__row { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--sm); + min-height: 36px; + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm); + /* border-radius: 4px; */ + cursor: pointer; + color: var(--c--contextuals--text--surface--primary); + font-size: var(--c--globals--font--sizes--sm); + outline: none; + + // [data-focused] is set by react-aria when the item has either real + // or virtual focus (arrow keys from the search field). The background + // change is enough as a focus indicator — adding a ring on top would + // be visual noise since hover and focus already share the same fill. + &[data-focused], + &[data-hovered] { + background-color: var(--c--contextuals--background--semantic--contextual--primary-hover); + } + + &[data-disabled] { + cursor: default; + opacity: 0.6; + } + + &[data-selected] { + .quick-assign-popover__row-name { + font-weight: 600; + } + } + + // Hide the check icon when the row is not selected so only assigned + // rows show it. Matches the design and avoids a layout shift on + // toggle (icon stays in the layout but invisible). + &:not([data-selected]) { + .quick-assign-popover__row-check { + visibility: hidden; + } + } +} + +.quick-assign-popover__row-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quick-assign-popover__row-check { + color: var(--c--contextuals--border--semantic--brand--primary); + flex-shrink: 0; +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/index.tsx new file mode 100644 index 000000000..0cab288a6 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover/index.tsx @@ -0,0 +1,305 @@ +import { Icon, IconSize, IconType, Spinner, UserAvatar } from "@gouvfr-lasuite/ui-kit"; +import { + Autocomplete, + Dialog, + Input, + Menu, + MenuItem, + Popover, + SearchField, + Selection, + useFilter, +} from "react-aria-components"; +import { RefObject, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ThreadAccessRoleChoices, + UserWithoutAbilities, + useThreadsAccessesList, +} from "@/features/api/gen"; +import { useAuth } from "@/features/auth"; +import { useThreadAssignment } from "@/features/message/use-thread-assignment"; +import { StringHelper } from "@/features/utils/string-helper"; + +type QuickAssignPopoverProps = { + isOpen: boolean; + triggerRef: RefObject; + onOpenChange: (open: boolean) => void; + threadId: string; +}; + +/** + * Compact popover that lets the current user toggle thread assignment for + * any teammate without opening the full share modal. + * + * A11y: + * - `Dialog` from react-aria-components provides role=dialog, focus trap, + * ESC-to-close and focus restoration on the trigger. + * - `Autocomplete` wires the search field to the menu so arrow keys move + * virtual focus across items while the user keeps typing. + * - `Menu` with `selectionMode="multiple"` exposes each row as + * role=menuitemcheckbox with proper aria-checked semantics. + * - `disabledKeys` blocks every user with an in-flight mutation; the + * others stay navigable, so keyboard users never lose their place + * and concurrent toggles can settle independently. + * - The check icon and the avatar are decorative — meaning is conveyed + * by aria-checked and the visible name. + * + * Filtering: only users coming from editor mailboxes are listed. Viewer + * mailboxes are excluded because assigning them would require a + * privilege escalation we don't surface here (the share modal handles it). + */ +export const QuickAssignPopover = ({ + isOpen, + triggerRef, + onOpenChange, + threadId, +}: QuickAssignPopoverProps) => { + const { t } = useTranslation(); + const { user: currentUser } = useAuth(); + const [announcement, setAnnouncement] = useState(""); + const { + assignedUserIds, + mutatingUserIds, + assignUser, + unassignUser, + } = useThreadAssignment(); + + const accessesQuery = useThreadsAccessesList( + threadId, + undefined, + { query: { enabled: isOpen && !!threadId } }, + ); + + // Distinct users across editor mailboxes only. Sorted with the current + // user first (one-click self-assign), then alphabetically by display + // name with email as a fallback for users whose `full_name` is null. + const users = useMemo(() => { + const editorAccesses = (accessesQuery.data?.data ?? []).filter( + (a) => a.role === ThreadAccessRoleChoices.editor, + ); + const seen = new Set(); + const list: UserWithoutAbilities[] = []; + for (const access of editorAccesses) { + for (const user of access.users) { + if (seen.has(user.id)) continue; + seen.add(user.id); + list.push(user); + } + } + list.sort((a, b) => { + if (currentUser && a.id === currentUser.id) return -1; + if (currentUser && b.id === currentUser.id) return 1; + const labelA = a.full_name ?? a.email ?? ""; + const labelB = b.full_name ?? b.email ?? ""; + return labelA.localeCompare(labelB); + }); + return list; + }, [accessesQuery.data?.data, currentUser]); + + // react-aria-components ships its own `useFilter` hook backed by the + // user's locale (handles diacritics correctly via Intl.Collator). We + // wrap it in an extra normalization pass for ligatures (œ → oe) which + // Intl.Collator's "base" sensitivity does not always cover. + const { contains } = useFilter({ sensitivity: "base" }); + const matchUser = (textValue: string, inputValue: string) => { + if (!inputValue) return true; + const haystack = StringHelper.normalizeForSearch(textValue); + const needle = StringHelper.normalizeForSearch(inputValue); + return contains(haystack, needle); + }; + + const handleSelectionChange = (newKeys: Selection) => { + if (newKeys === "all") return; + // Diff the new selection against the current server-known state. + // Exactly one delta is expected per user click (Menu fires once + // per toggle), but we walk both sides to be safe. The live-region + // announcement is not fired here: it is derived from the + // server-confirmed `assignedUserIds` change in the effect below, + // so a failed mutation never produces a misleading success message. + for (const key of newKeys) { + const id = String(key); + if (!assignedUserIds.has(id)) { + const user = users.find((u) => u.id === id); + if (user) { + assignUser(user); + } + return; + } + } + for (const id of assignedUserIds) { + if (!newKeys.has(id)) { + unassignUser(id); + return; + } + } + }; + + // Announce assignment changes only after the server confirms them. + // We diff the previous `assignedUserIds` Set against the current one + // (stabilized via useMemo in `useThreadAssignment`) and announce the + // arriving or leaving user. The first render is skipped so existing + // assignees aren't read out when the popover mounts. + const previousAssignedRef = useRef | null>(null); + useEffect(() => { + const previous = previousAssignedRef.current; + previousAssignedRef.current = assignedUserIds; + if (previous === null) return; + + for (const id of assignedUserIds) { + if (!previous.has(id)) { + const user = users.find((u) => u.id === id); + setAnnouncement( + t('{{name}} assigned to this thread', { + name: user?.full_name || user?.email || "", + }), + ); + return; + } + } + for (const id of previous) { + if (!assignedUserIds.has(id)) { + const user = users.find((u) => u.id === id); + setAnnouncement( + t('{{name}} unassigned from this thread', { + name: user?.full_name || user?.email || "", + }), + ); + return; + } + } + }, [assignedUserIds, users, t]); + + const disabledKeys = mutatingUserIds.size > 0 ? mutatingUserIds : undefined; + + return ( + + +
{ + // Close on Esc no matter what currently has focus. + // Without this, react-aria's SearchField/Autocomplete/Menu + // stack can interpret Esc as a deselect on the virtually + // focused row, which fires onSelectionChange and unassigns + // a user instead of dismissing the popover. + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onOpenChange(false); + } + }} + > + + + + } + disabledKeys={disabledKeys} + onSelectionChange={handleSelectionChange} + renderEmptyState={() => ( + accessesQuery.isLoading ? ( +
+ + + {t('Loading users')} + +
+ ) : ( +
+ {t('No matching users')} +
+ ) + )} + className="quick-assign-popover__list" + > + {(user) => { + const isMe = currentUser?.id === user.id; + const displayName = isMe + ? t('You') + : user.full_name || user.email || ""; + // textValue powers Autocomplete filtering and + // the screen-reader label for the row. Joining + // name + email lets users find each other by + // either; "Me" is dropped on purpose so users + // type their actual name to find themselves. + const textValue = [user.full_name, user.email] + .filter(Boolean) + .join(" "); + const isMutating = mutatingUserIds.has(user.id); + return ( + + + + {displayName} + + {isMutating ? ( + + ) : ( + + ); + }} +
+
+
+ {announcement} +
+
+
+
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/_index.scss new file mode 100644 index 000000000..14b04973f --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/_index.scss @@ -0,0 +1,198 @@ +.c__share-modal:has(.share-modal-extensions__assigned) .c__separator { + margin-bottom: 0; +} + +.share-modal-extensions { + // ---------- shared row primitive ---------- + // Used by both the "Assigned" section at the top of the modal and the + // per-mailbox users sub-list so a user looks identical wherever they + // appear. The row hosts a `` upstream component (avatar + + // name + email) followed by the assign/unassign toggle. + + &__row { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + padding: var(--c--globals--spacings--2xs) var(--c--globals--spacings--xs); + border-radius: 4px; + + &:hover { + background: var(--c--contextuals--background--surface--tertiary); + } + + // Let the upstream UserRow consume the available width so the + // toggle pinpoints to the right edge. + > .c__user-row { + flex: 1; + min-width: 0; + } + } + + // The mailbox rows (rendered by upstream `c__share-member-item`) get + // the same hover affordance as the sub-list rows so the whole list + // reacts consistently to pointer movement. + &__member > .c__share-member-item { + border-radius: 4px; + transition: background 120ms ease-out; + + &:hover { + background: var(--c--contextuals--background--surface--tertiary); + } + } + + // ---------- "assigned" avatar badge ---------- + // The state of an assigned user is signalled by a small brand-colored + // bubble in the top-right corner of their avatar, carrying a `person` + // glyph rendered through the Material Icons font. This satisfies RGAA + // 9.4 / WCAG 1.4.1 — the state is conveyed by both colour AND a + // distinct shape, not colour alone. + // + // Implemented via `::after` on the upstream `.c__avatar` so we don't + // have to fork the surrounding components (UserRow, ShareMemberItem) + // to inject DOM. The trade-off is that screen readers can't read a + // pseudo-element — the parent row provides `aria-label="Assigned to + // this thread"` for that. + + &__row--assigned .c__avatar, + &__share-member-item--assigned .c__avatar { + position: relative; + + &::after { + // Mirror Cunningham's `.material-icons` rule because pseudo- + // elements can't inherit class-level styles. The "liga" feature + // setting is what turns the `person` literal into the glyph. + content: "person"; + font-family: 'Material Icons'; + font-weight: 400; + font-style: normal; + font-feature-settings: "liga"; + font-size: 11px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + position: absolute; + top: -6px; + right: -7px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--c--contextuals--background--semantic--neutral--primary); + color: color-mix(in srgb, var(--c--contextuals--background--surface--tertiary) 95%, transparent); + border: 1px solid color-mix(in srgb, var(--c--contextuals--background--surface--tertiary) 80%, transparent); + display: flex; + align-items: center; + justify-content: center; + } + } + + // ---------- toggle button reveal ---------- + // Assign / Unassign buttons stay invisible until the row is hovered or + // a child gets keyboard focus — keeps the list quiet by default while + // remaining a11y-accessible. + + &__toggle { + opacity: 0; + transition: opacity 120ms ease-out; + } + + &__row:hover &__toggle, + &__row:focus-within &__toggle, + &__member > .c__share-member-item:hover &__toggle, + &__member > .c__share-member-item:focus-within &__toggle, + &__toggle:focus-visible { + opacity: 1; + } + + // Visually distinguish shared (non-identity) mailboxes from personal + // ones by swapping the avatar initials for a `group` glyph. Both stay + // round — the icon alone signals "team" vs "person" so the layout + // reads consistently across the list. + // + // Two anchors trigger the treatment: + // - `__share-member-item--shared` for mailbox rows in the members list, + // - `__shared-avatar` for entries in the search-results dropdown + // (wraps the upstream UserRow whose c__avatar we can't tag directly). + &__share-member-item--shared .c__avatar, + &__shared-avatar .c__avatar { + position: relative; + + // Hide the upstream initials without removing them from the DOM — + // keeps the avatar's intrinsic sizing untouched. + .c__avatar__initials { + visibility: hidden; + } + + &::before { + content: "group"; + font-family: 'Material Icons'; + font-feature-settings: "liga"; + font-weight: 400; + font-style: normal; + font-size: 16px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: inherit; + } + } + + // ---------- "Assigned to N people" section ---------- + + &__assigned { + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--2xs); + margin-bottom: var(--c--globals--spacings--md); + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base); + background: var(--c--contextuals--background--surface--tertiary); + border-bottom: 1px solid var(--c--contextuals--border--surface--primary); + + &__title { + font-size: var(--c--globals--typography--font-size--sm); + font-weight: 600; + color: var(--c--globals--colors--greyscale--700); + margin: 0 0 var(--c--globals--spacings--2xs) 0; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--c--globals--spacings--2xs); + } + } + + // ---------- per-mailbox users sub-list ---------- + + &__users { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + + // Inset the user rows so they read as belonging to the mailbox row above. + > .share-modal-extensions__row { + padding-left: var(--c--globals--spacings--xl); + } + } + + // ---------- modal footer ---------- + + &__footer { + display: flex; + justify-content: flex-end; + padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm); + border-top: 1px solid var(--c--contextuals--border--surface--primary); + } +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-role-dropdown.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-role-dropdown.tsx new file mode 100644 index 000000000..b27c11755 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-role-dropdown.tsx @@ -0,0 +1,99 @@ +// FORK: reproduced from @gouvfr-lasuite/ui-kit AccessRoleDropdown since the +// original component is not publicly exported. Used by the invitation bar +// and member rows to pick a role via DropdownMenu. +import { useMemo } from "react"; +import { Button, useCunningham } from "@gouvfr-lasuite/cunningham-react"; +import { + DropdownMenu, + type DropdownMenuItem, + type DropdownMenuOption, + type DropdownMenuProps, +} from "@gouvfr-lasuite/ui-kit"; + +type AccessRoleDropdownProps = { + selectedRole: string; + roles: DropdownMenuOption[]; + onSelect: (role: string) => void; + canUpdate?: boolean; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; + roleTopMessage?: DropdownMenuProps["topMessage"]; + onDelete?: () => void; + canDelete?: boolean; +}; + +export const AccessRoleDropdown = ({ + roles, + onSelect, + canUpdate = true, + selectedRole, + isOpen, + onOpenChange, + roleTopMessage, + onDelete, + canDelete = true, +}: AccessRoleDropdownProps) => { + // Aliased to `tc` so the i18next-cli parser does not extract Cunningham's + // own translation keys (e.g. `components.share.*`) into our locale files. + const { t: tc } = useCunningham(); + + const currentRoleString = roles.find((role) => role.value === selectedRole); + + const options: DropdownMenuItem[] = useMemo(() => { + if (!onDelete) { + return roles; + } + return [ + ...roles, + { type: "separator" as const }, + { + label: tc("components.share.access.delete"), + callback: onDelete, + isDisabled: !canDelete, + }, + ]; + }, [roles, onDelete, tc, canDelete]); + + if (!canUpdate) { + return ( + + {currentRoleString?.label} + + ); + } + + return ( + { + const isAccessRoleDropdown = element.closest(".c__access-role-dropdown"); + if (isAccessRoleDropdown) return false; + return true; + }} + onOpenChange={onOpenChange} + options={options} + selectedValues={[selectedRole]} + onSelectValue={onSelect} + topMessage={roleTopMessage} + > + + + ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-users-list.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-users-list.tsx new file mode 100644 index 000000000..8ca51ac22 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/access-users-list.tsx @@ -0,0 +1,94 @@ +import { Spinner, UserRow } from "@gouvfr-lasuite/ui-kit"; +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import type { ThreadAccessDetail, UserWithoutAbilities } from "@/features/api/gen"; + +type AccessWithUsers = ThreadAccessDetail & { + users: readonly UserWithoutAbilities[]; +}; + +type AccessUsersListProps = { + access: AccessWithUsers; + assignedUserIds: ReadonlySet; + canAssign: boolean; + mutatingUserIds: ReadonlySet; + onAssign: (user: UserWithoutAbilities, access: AccessWithUsers) => void; + onUnassign: (userId: string) => void; +}; + +/** + * Renders the users of a mailbox below its row, but only when listing them + * adds information beyond what the mailbox row already shows. A + * single-user identity mailbox is essentially a synonym for its user, so + * we skip the sub-list there and let the parent widget render the toggle + * inline next to the role select. + * + * Each row reuses the upstream `` (avatar + name + email) so the + * layout matches the mailbox rows exactly. Assigned users get a brand-color + * "person" badge in the top-right of their avatar (rendered via CSS + * pseudo-element on `.c__avatar`); the explicit "Assign / Unassign" + * button next to it carries the actual toggle. + */ +export const AccessUsersList = ({ + access, + assignedUserIds, + canAssign, + mutatingUserIds, + onAssign, + onUnassign, +}: AccessUsersListProps) => { + const { t } = useTranslation(); + if (!access.users || access.users.length === 0) return null; + + const isSingleIdentityUser = + access.users.length === 1 && access.mailbox.is_identity !== false; + if (isSingleIdentityUser) return null; + + return ( +
    + {access.users.map((user) => { + const isAssigned = assignedUserIds.has(user.id); + const isMutating = mutatingUserIds.has(user.id); + return ( +
  • + + {canAssign && (isAssigned ? ( + + ) : ( + + ))} +
  • + ); + })} +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/assigned-users-section.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/assigned-users-section.tsx new file mode 100644 index 000000000..c6885887b --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/assigned-users-section.tsx @@ -0,0 +1,62 @@ +import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { UserRow } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; + +type AssignedUsersSectionProps = { + assignedUsers: ReadonlyArray<{ id: string; name: string; email?: string }>; + canUpdate: boolean; + mutatingUserIds: ReadonlySet; + onUnassign: (userId: string) => void; +}; + +/** + * Rendered through the ShareModal `children` slot, above the members list. + * Title is dynamic ("Assigné à N personne") so the user instantly sees how + * many people are in charge of this thread. + * + * Rows here deliberately omit the `__row--assigned` modifier (and thus the + * person badge on the avatar): every user listed in this section is + * assigned by definition, so the section heading already carries that + * information — repeating it on each avatar would be noise. + */ +export const AssignedUsersSection = ({ assignedUsers, canUpdate, mutatingUserIds, onUnassign }: AssignedUsersSectionProps) => { + const { t } = useTranslation(); + + if (assignedUsers.length === 0) return null; + + return ( +
+

+ {t('Assigned to {{count}} people', { + count: assignedUsers.length, + defaultValue_one: 'Assigned to {{count}} person', + })} +

+
    + {assignedUsers.map((user) => ( +
  • + + {canUpdate && ( + + )} +
  • + ))} +
+
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/index.ts b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/index.ts new file mode 100644 index 000000000..eaa65a16a --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/index.ts @@ -0,0 +1,4 @@ +export { AssignedUsersSection } from "./assigned-users-section"; +export { AccessUsersList } from "./access-users-list"; +export { ShareModal } from "./share-modal"; +export type { ShareModalProps } from "./share-modal"; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/invitation-user-selector.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/invitation-user-selector.tsx new file mode 100644 index 000000000..6d64a877f --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/invitation-user-selector.tsx @@ -0,0 +1,90 @@ +// FORK: reproduced from @gouvfr-lasuite/ui-kit InvitationUserSelectorList +// since the upstream component is not publicly exported. Preserves the +// upstream CSS classes (c__add-share-user-list, c__add-share-user-item) +// so the visual styling bundled in ui-kit's style.css applies as-is. +import { ReactNode, useState } from "react"; +import { Button, useCunningham } from "@gouvfr-lasuite/cunningham-react"; +import type { DropdownMenuOption, UserData } from "@gouvfr-lasuite/ui-kit"; +import { useTranslation } from "react-i18next"; +import { AccessRoleDropdown } from "./access-role-dropdown"; + +export type InvitationUserSelectorListProps = { + users: UserData[]; + onRemoveUser: (user: UserData) => void; + rightActions?: ReactNode; + onShare: () => void; + roles: DropdownMenuOption[]; + selectedRole: string; + shareButtonLabel?: string; + onSelectRole: (role: string) => void; +}; + +export const InvitationUserSelectorList = ({ + users, + onRemoveUser, + rightActions, + onShare, + shareButtonLabel, + roles, + selectedRole, + onSelectRole, +}: InvitationUserSelectorListProps) => { + // Aliased to `tc` so the i18next-cli parser does not extract Cunningham's + // own translation keys (e.g. `components.share.*`) into our locale files. + const { t: tc } = useCunningham(); + const [isOpen, setIsOpen] = useState(false); + return ( +
+
+ {users.map((user) => ( + + ))} +
+
+ {rightActions} + + +
+
+ ); +}; + +type InvitationUserSelectorItemProps = { + user: UserData; + onRemoveUser: (user: UserData) => void; +}; + +const InvitationUserSelectorItem = ({ + user, + onRemoveUser, +}: InvitationUserSelectorItemProps) => { + const { t } = useTranslation(); + const displayName = user.full_name || user.email; + return ( +
+ {displayName} +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-member-item.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-member-item.tsx new file mode 100644 index 000000000..77da8dd2f --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-member-item.tsx @@ -0,0 +1,81 @@ +// FORK: reproduced from @gouvfr-lasuite/ui-kit ShareMemberItem so we can +// inject a `rightExtras` slot (e.g. an inline "Assign" CTA) next to the +// role dropdown. CSS classes (`c__share-member-item`, `c__share-member-item__right`) +// are preserved so ui-kit's bundled styles apply as-is. +import { ReactNode, useState } from "react"; +import { + type AccessData, + QuickSearchItemTemplate, + UserRow, + type DropdownMenuOption, + type DropdownMenuProps, +} from "@gouvfr-lasuite/ui-kit"; +import clsx from "clsx"; +import { AccessRoleDropdown } from "./access-role-dropdown"; + +export type ShareMemberItemProps = { + accessData: AccessData; + roles: DropdownMenuOption[]; + updateRole?: (access: AccessData, role: string) => void; + deleteAccess?: (access: AccessData) => void; + canUpdate?: boolean; + roleTopMessage?: DropdownMenuProps["topMessage"]; + accessRoleKey?: keyof AccessData; + rightExtras?: ReactNode; + /** + * Optional extra class on the row wrapper. Used by consumers to flag + * row-level state (e.g. assignment) that the upstream component does + * not know about — CSS hooks into it to decorate the avatar. + */ + wrapperClassName?: string; +}; + +export const ShareMemberItem = ({ + accessData, + accessRoleKey = "role", + roles, + updateRole, + deleteAccess, + canUpdate = true, + roleTopMessage, + rightExtras, + wrapperClassName, +}: ShareMemberItemProps) => { + const [isRoleOpen, setIsRoleOpen] = useState(false); + const accessFlags = accessData as { is_explicit?: boolean; can_delete?: boolean }; + const canDelete = + Boolean(deleteAccess) && + accessFlags.is_explicit !== false && + accessFlags.can_delete !== false; + return ( +
+ + } + alwaysShowRight={true} + right={ +
+ {rightExtras} + updateRole?.(accessData, role)} + isOpen={isRoleOpen} + onOpenChange={setIsRoleOpen} + canUpdate={canUpdate} + roleTopMessage={roleTopMessage} + canDelete={canDelete} + onDelete={deleteAccess ? () => deleteAccess(accessData) : undefined} + /> +
+ } + /> +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx new file mode 100644 index 000000000..f80c83360 --- /dev/null +++ b/src/frontend/src/features/layouts/components/thread-view/components/share-modal-extensions/share-modal.tsx @@ -0,0 +1,543 @@ +// FORK: copied from @gouvfr-lasuite/ui-kit v0.20.0 ShareModal to add a +// `renderAccessFooter` slot rendered below each access row (to display +// per-mailbox assignable users + "Assign" CTA). All other behavior is +// preserved. Target for upstream contribution. +// +// Differences vs upstream: +// - Imports use public ui-kit exports only (no ":/components/..." aliases) +// - Added `renderAccessFooter?: (access) => ReactNode` prop +// - Added SearchUserItem inline (not exported by ui-kit) +// - Removed ShareLinkSettings (unused by our consumer, keeps fork lean) +import { + useState, + useRef, + useMemo, + PropsWithChildren, + ReactNode, + useCallback, + useEffect, +} from "react"; +import { + Button, + Modal, + ModalSize, + useCunningham, +} from "@gouvfr-lasuite/cunningham-react"; +import { + type AccessData, + type InvitationData, + QuickSearch, + type QuickSearchData, + QuickSearchGroup, + QuickSearchItemTemplate, + ShareInvitationItem, + type DropdownMenuOption, + type UserData, + UserRow, + useResponsive, +} from "@gouvfr-lasuite/ui-kit"; +import { InvitationUserSelectorList } from "./invitation-user-selector"; +import { ShareMemberItem } from "./share-member-item"; + +enum ViewMode { + CANNOT_VIEW = "cannot_view", + SEARCH = "search", + EMPTY = "empty", +} + +type SearchUserItemProps = { + user: UserData; +}; + +// Reproduced locally because ui-kit does not export it. +const SearchUserItem = ({ user }: SearchUserItemProps) => { + // Aliased to `tc` so the i18next-cli parser does not extract Cunningham's + // own translation keys (e.g. `components.share.*`) into our locale files. + const { t: tc } = useCunningham(); + // When the searchable entity is a Mailbox (the only consumer of this fork + // today), `is_identity === false` flags shared mailboxes — wrap the row + // so the SCSS rule that swaps initials for a `group` glyph also fires + // here. Cast tolerates non-Mailbox UserType silently. + const isShared = (user as { is_identity?: boolean }).is_identity === false; + return ( + + +
+ } + alwaysShowRight={false} + right={ +
+ {tc("components.share.item.add")} + add +
+ } + /> + ); +}; + +type ShareModalInvitationProps = { + invitations?: InvitationData[]; + onUpdateInvitation?: ( + invitation: InvitationData, + role: string, + ) => void; + onDeleteInvitation?: ( + invitation: InvitationData, + ) => void; + hasNextInvitations?: boolean; + onLoadNextInvitations?: () => void; + invitationRoleTopMessage?: ( + invitation: InvitationData, + ) => string; +}; + +type ShareModalAccessProps = { + accesses?: AccessData[]; + accessRoleKey?: keyof AccessData; + hasNextMembers?: boolean; + onLoadNextMembers?: () => void; + onDeleteAccess?: (access: AccessData) => void; + onUpdateAccess?: ( + access: AccessData, + role: string, + ) => void; + accessRoleTopMessage?: ( + access: AccessData, + ) => string | ReactNode | undefined; + /** + * Fork-only extension: rendered directly below each access row inside + * the members list. Used for the per-mailbox assignable users list. + */ + renderAccessFooter?: ( + access: AccessData, + ) => ReactNode; + /** + * Fork-only extension: rendered on the right side of each access row, + * inline with the role dropdown. Used to surface an "Assign" CTA on + * mailbox rows with a single user (no sub-list needed). + */ + renderAccessRightExtras?: ( + access: AccessData, + ) => ReactNode; + /** + * Fork-only extension: extra class name applied to the access row + * wrapper. Used to flag row-level state (e.g. assignment) so CSS can + * decorate the upstream UserRow's avatar. + */ + getAccessClassName?: ( + access: AccessData, + ) => string | undefined; +}; + +type ShareModalSearchProps = { + searchUsersResult?: UserData[]; + onSearchUsers?: (search: string) => void; + searchPlaceholder?: string; + /** + * Fork-only extension: overrides the heading rendered above the search + * results group (defaults to Cunningham's + * `components.share.search.group_name`). + */ + searchGroupName?: string; + onInviteUser?: (users: UserData[], role: string) => void; + loading?: boolean; + /** + * Fork-only extension: when `false`, typing an email that does not + * match any search result will NOT surface an "invite" action. Only + * users returned by `onSearchUsers` can be selected. Defaults to `true`. + */ + allowInvitation?: boolean; +}; + +export type ShareModalProps = { + modalTitle?: string; + isOpen: boolean; + canUpdate?: boolean; + canView?: boolean; + cannotViewChildren?: ReactNode; + cannotViewMessage?: string; + onClose: () => void; + invitationRoles?: DropdownMenuOption[]; + getAccessRoles?: ( + access: AccessData, + ) => DropdownMenuOption[]; + outsideSearchContent?: ReactNode; + hideInvitations?: boolean; + hideMembers?: boolean; + /** + * Fork-only: override the default "N members" section heading (which + * otherwise comes from `useCunningham()` translations). + */ + membersTitle?: (members: AccessData[]) => ReactNode; +} & ShareModalInvitationProps + & ShareModalAccessProps + & ShareModalSearchProps; + +export const ShareModal = ({ + searchUsersResult, + children, + outsideSearchContent, + accesses: members = [], + invitations = [], + hasNextMembers = false, + canUpdate = true, + canView = true, + hasNextInvitations = false, + hideInvitations = false, + hideMembers = false, + cannotViewChildren, + renderAccessFooter, + renderAccessRightExtras, + getAccessClassName, + membersTitle, + allowInvitation = true, + ...props +}: PropsWithChildren< + ShareModalProps +>) => { + if (!(hideInvitations && hideMembers)) { + if (!props.invitationRoles) { + throw new Error("invitationRoles is required"); + } + if (!props.onSearchUsers) { + throw new Error("onSearchUsers is required"); + } + } + if (!hideInvitations && !props.onInviteUser) { + throw new Error("onInviteUser is required"); + } + if (canUpdate && !canView) { + throw new Error("canView cannot be false if canUpdate is true"); + } + + // Aliased to `tc` so the i18next-cli parser does not extract Cunningham's + // own translation keys (e.g. `components.share.*`) into our locale files. + const { t: tc } = useCunningham(); + const { isMobile } = useResponsive(); + const searchUserTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (searchUserTimeoutRef.current) { + clearTimeout(searchUserTimeoutRef.current); + } + }; + }, []); + + const [listHeight, setListHeight] = useState("400px"); + const selectedUsersRef = useRef(null); + const [inputValue, setInputValue] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [pendingInvitationUsers, setPendingInvitationUsers] = useState< + UserData[] + >([]); + const [selectedInvitationRole, setSelectedInvitationRole] = useState( + props.invitationRoles?.[0]?.value ?? "", + ); + + const modalContentHeight = !isMobile + ? "min(690px, calc(100dvh - 2em - 12px - 32px))" + : `calc(100dvh - 32px)`; + + const onSearchUser = (search: string) => { + if (searchUserTimeoutRef.current) { + clearTimeout(searchUserTimeoutRef.current); + } + if (search === "") { + setSearchQuery(""); + props.onSearchUsers!(""); + return; + } + searchUserTimeoutRef.current = setTimeout(() => { + props.onSearchUsers!(search); + setSearchQuery(search); + }, 300); + }; + + const onInputChange = (str: string) => { + setInputValue(str); + onSearchUser(str); + }; + + const showSearchUsers = + searchQuery !== "" || pendingInvitationUsers.length > 0; + + const onSelect = useCallback( + (user: UserData) => { + setPendingInvitationUsers((prev) => [...prev, user]); + setInputValue(""); + setSearchQuery(""); + props.onSearchUsers!(""); + }, + [props], + ); + + const onRemoveUser = (user: UserData) => { + setPendingInvitationUsers((prev) => prev.filter((u) => u.id !== user.id)); + }; + + const usersData: QuickSearchData> = useMemo(() => { + // TODO(upstream): port this id-based filter back to ui-kit's ShareModal. + // Upstream uses `.includes(user)` which relies on reference equality; + // after a search refetch the server returns freshly allocated objects, + // so already-pending users would reappear in the list and could be + // picked twice. + const pendingIds = new Set(pendingInvitationUsers.map((u) => u.id)); + const searchMemberResult = searchUsersResult?.filter( + (user) => !pendingIds.has(user.id), + ); + let emptyString: string | undefined = + searchQuery !== "" + ? tc("components.share.user.no_result") + : props.searchPlaceholder ?? tc("components.share.user.placeholder"); + + const isValidEmail = (email: string) => + !!email.match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z\-0-9]{2,}))$/, + ); + + const isInvitationMode = + allowInvitation && + isValidEmail(searchQuery ?? "") && + !searchMemberResult?.some((user) => user.email === searchQuery); + + const newUser = { + id: searchQuery, + full_name: "", + email: searchQuery, + }; + + if (isInvitationMode) { + emptyString = undefined; + } + + return { + groupName: + props.searchGroupName ?? tc("components.share.search.group_name"), + elements: searchMemberResult ?? [], + showWhenEmpty: true, + emptyString, + endActions: isInvitationMode + ? [ + { + content: , + onSelect: () => void onSelect(newUser as UserData), + }, + ] + : undefined, + } satisfies QuickSearchData>; + }, [searchUsersResult, searchQuery, tc, pendingInvitationUsers, onSelect, allowInvitation, props.searchPlaceholder, props.searchGroupName]); + + const handleRef = (node: HTMLDivElement) => { + const inputHeight = 70; + const footerHeight = node?.clientHeight ?? 0; + const selectedUsersHeight = selectedUsersRef.current?.clientHeight ?? 0; + const height = `calc(${modalContentHeight} - ${footerHeight}px - ${selectedUsersHeight}px - ${inputHeight}px - 10px)`; + setListHeight(height); + }; + + const showInvitations = + !hideInvitations && + !showSearchUsers && + !props.loading && + invitations.length > 0; + + const showMembers = + !hideMembers && !showSearchUsers && !props.loading && members.length > 0; + + const getViewMode = () => { + if (!canView) return ViewMode.CANNOT_VIEW; + if (!(hideInvitations && hideMembers)) return ViewMode.SEARCH; + return ViewMode.EMPTY; + }; + + const viewMode = getViewMode(); + + return ( + +
+ {canUpdate && pendingInvitationUsers.length > 0 && ( +
+ { + props.onInviteUser!( + pendingInvitationUsers, + selectedInvitationRole, + ); + setPendingInvitationUsers([]); + }} + /> +
+ )} + + {viewMode === ViewMode.CANNOT_VIEW && ( +
+
+

+ {props.cannotViewMessage ?? + tc("components.share.cannot_view.message")} +

+
+ {cannotViewChildren} +
+ )} + + {viewMode === ViewMode.SEARCH && ( + +
+ {showSearchUsers && ( +
+ { + onSelect(user); + }} + renderElement={(user) => } + /> +
+ )} + + {!showSearchUsers && children} + + {showInvitations && ( +
+ + {tc("components.share.invitations.title")} + + {invitations.map((invitation) => ( + + ))} + +
+ )} + + {showMembers && ( +
+ + {membersTitle + ? membersTitle(members) + : tc( + members.length > 1 + ? "components.share.members.title_plural" + : "components.share.members.title_singular", + { count: members.length }, + )} + + {members.map((member) => ( +
+ + {renderAccessFooter?.(member)} +
+ ))} + +
+ )} +
+
+ )} + +
+ {!showSearchUsers && outsideSearchContent && ( +
{outsideSearchContent}
+ )} +
+
+
+ ); +}; + +type ShowMoreButtonProps = { + show: boolean; + onShowMore?: () => void; +}; + +const ShowMoreButton = ({ show, onShowMore }: ShowMoreButtonProps) => { + // Aliased to `tc` so the i18next-cli parser does not extract Cunningham's + // own translation keys (e.g. `components.share.*`) into our locale files. + const { t: tc } = useCunningham(); + if (!show) return null; + return ( +
+ +
+ ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-accesses-widget/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-accesses-widget/index.tsx index cd6cc7f7e..96520e4fd 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-accesses-widget/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-accesses-widget/index.tsx @@ -1,118 +1,282 @@ -import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react" -import { Icon, IconType, ShareModal } from "@gouvfr-lasuite/ui-kit" -import { useState } from "react"; -import { ThreadAccessRoleChoices, ThreadAccessDetail, MailboxLight } from "@/features/api/gen/models"; +import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react"; +import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit"; +import { useQueryClient } from "@tanstack/react-query"; +import { forwardRef, useImperativeHandle, useMemo, useState } from "react"; +import { + ThreadAccess, + ThreadAccessRoleChoices, + ThreadAccessDetail, + MailboxLight, + UserWithoutAbilities, + getThreadsAccessesListQueryKey, + threadsAccessesListResponse, + useMailboxesSearchList, + useThreadsAccessesCreate, + useThreadsAccessesDestroy, + useThreadsAccessesList, + useThreadsAccessesUpdate, +} from "@/features/api/gen"; import { useMailboxContext } from "@/features/providers/mailbox"; import { useTranslation } from "react-i18next"; -import { useMailboxesSearchList, useThreadsAccessesCreate, useThreadsAccessesDestroy, useThreadsAccessesUpdate } from "@/features/api/gen"; import { addToast, ToasterItem } from "@/features/ui/components/toaster"; import useAbility, { Abilities } from "@/hooks/use-ability"; +import { useIsSharedContext } from "@/hooks/use-is-shared-context"; +import { useAssignedUsers } from "@/features/message/use-assigned-users"; +import { useThreadAssignment } from "@/features/message/use-thread-assignment"; +import { AssignedUsersSection, AccessUsersList, ShareModal } from "../share-modal-extensions"; - +export type ThreadAccessesWidgetHandle = { + open: () => void; +}; type ThreadAccessesWidgetProps = { accesses: readonly ThreadAccessDetail[]; -} +}; + +/** + * Display shape consumed by the ShareModal: `accesses` from Thread (nested + * mailbox for rendering) joined with `users` from the `/accesses/` endpoint. + * The join is required because `/accesses/` returns the mailbox as a flat FK + * UUID — it's the authoritative source for `users`, but Thread.accesses + * remains the source for mailbox display info. + */ +type EnrichedAccess = ThreadAccessDetail & { + users: readonly UserWithoutAbilities[]; +}; /** - * A Component which list all thread accesses and allow to manage them. - * This feature is still under development and requires several improvements : - * - Prevent deletion if there is only one editor - * - Ask user confirmation before downgrading its access that remove its write right - * - In the ShareModal, identify the authenticated user (suffix the name with (You)) + * Lists thread accesses and lets users manage sharing + per-user assignment. + * Exposes an `open()` handle so the `AssigneesWidget` (rendered inside + * `ThreadActionBar`) can reuse the exact same modal without duplicating state. * - * To achieve those developments, the `ui-kit` ShareModel must be improved. + * The `ShareModal` from `@gouvfr-lasuite/ui-kit` is reused as-is for visual + * consistency; assignment affordances are injected through its extension + * points (`children` for the "assigned users" section, `accessRoleTopMessage` + * returning a ReactNode for the per-mailbox user list). */ -export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) => { +export const ThreadAccessesWidget = forwardRef( + function ThreadAccessesWidget({ accesses }, ref) { const { t } = useTranslation(); const [isShareModalOpen, setIsShareModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const { selectedMailbox, selectedThread, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext(); + const { + selectedMailbox, + selectedThread, + invalidateThreadMessages, + invalidateThreadsStats, + unselectThread, + } = useMailboxContext(); const modals = useModals(); - const { mutate: removeThreadAccess } = useThreadsAccessesDestroy({ mutation: { onSuccess: () => invalidateThreadMessages() } }); - const { mutate: createThreadAccess } = useThreadsAccessesCreate({ mutation: { onSuccess: () => invalidateThreadMessages() } }); - const { mutate: updateThreadAccess } = useThreadsAccessesUpdate({ mutation: { onSuccess: () => invalidateThreadMessages() } }); - const searchMailboxesQuery = useMailboxesSearchList(selectedMailbox?.id ?? "", { - q: searchQuery, - }, { - query: { - enabled: !!(selectedMailbox && searchQuery), - } + const assignedUsers = useAssignedUsers(); + const { + assignedUserIds, + mutatingUserIds, + markUserMutating, + clearUserMutating, + dispatchAssignEvent, + unassignUser, + } = useThreadAssignment(); + const queryClient = useQueryClient(); + + useImperativeHandle(ref, () => ({ + open: () => setIsShareModalOpen(true), + }), []); + + // Any mutation on thread accesses (add/update/remove member) also + // impacts the thread messages (roles gate UI abilities), hence the + // shared invalidation below. + // + // For the accesses list itself we prefer a targeted cache patch on + // success rather than a refetch: the backend response already contains + // the new/updated row, so we apply it directly to skip a round-trip + // that would otherwise leave the select showing the old value + // between mutation success and refetch completion. Create is the only + // case that still triggers a refetch — the response lacks the + // per-mailbox `users` list required by the modal. + const patchAccessesCache = ( + updater: (prev: ThreadAccess[]) => ThreadAccess[], + ) => { + if (!selectedThread?.id) return; + queryClient.setQueryData( + getThreadsAccessesListQueryKey(selectedThread.id), + (old) => (old ? { ...old, data: updater(old.data) } : old), + ); + }; + + const removeMutation = useThreadsAccessesDestroy({ + mutation: { + onSuccess: (_data, vars) => { + invalidateThreadMessages(); + patchAccessesCache((prev) => prev.filter((a) => a.id !== vars.id)); + }, + }, + }); + const createMutation = useThreadsAccessesCreate({ + mutation: { + onSuccess: () => { + invalidateThreadMessages(); + if (selectedThread?.id) { + queryClient.invalidateQueries({ + queryKey: getThreadsAccessesListQueryKey(selectedThread.id), + }); + } + }, + }, }); + const updateMutation = useThreadsAccessesUpdate({ + mutation: { + onSuccess: (data) => { + invalidateThreadMessages(); + patchAccessesCache((prev) => + prev.map((a) => + a.id === data.data.id ? { ...a, role: data.data.role } : a, + ), + ); + }, + }, + }); + // Per-row pending state: while a mutation is in flight for a given + // access, the modal shows a spinner next to that row so the user + // knows their click registered. + const isAccessPending = (accessId: string) => + (updateMutation.isPending && updateMutation.variables?.id === accessId) || + (removeMutation.isPending && removeMutation.variables?.id === accessId); + const isMailboxPending = (mailboxId: string) => + createMutation.isPending && + createMutation.variables?.data.mailbox === mailboxId; + + const searchMailboxesQuery = useMailboxesSearchList( + selectedMailbox?.id ?? "", + { q: searchQuery }, + { query: { enabled: !!(selectedMailbox && searchQuery) } }, + ); + + const canManageThreadAccess = useAbility(Abilities.CAN_MANAGE_THREAD_ACCESS, [selectedMailbox!, selectedThread!]); + const isAssignmentContext = useIsSharedContext(); + + const threadAccessesQuery = useThreadsAccessesList( + selectedThread?.id ?? "", + undefined, + { + query: { + enabled: + !!selectedThread?.id && isShareModalOpen && canManageThreadAccess, + }, + }, + ); + // Join Thread.accesses (nested mailbox, no users) with /accesses/ + // (flat mailbox FK + users per access). The /accesses/ endpoint is + // the source of truth for `role` and `users`: it is patched + // synchronously by `patchAccessesCache` on update/remove, so it + // reflects the new state before the thread payload refetch + // completes. The thread prop is the only source for the nested + // mailbox object required by the modal — we keep it as the join + // base. Viewers (who can't manage accesses) get no /accesses/ + // response and fall back to the prop with empty `users`. + const accessesById = useMemo( + () => new Map(accesses.map((access) => [access.id, access])), + [accesses], + ); + const enrichedAccesses: readonly EnrichedAccess[] = useMemo(() => { + const fresh = threadAccessesQuery.data?.data; + if (fresh) { + return fresh + .map((access) => { + const base = accessesById.get(access.id); + return base + ? { ...base, role: access.role, users: access.users ?? [] } + : null; + }) + .filter((a): a is EnrichedAccess => a !== null); + } + return accesses.map((access) => ({ ...access, users: [] })); + }, [accesses, accessesById, threadAccessesQuery.data?.data]); + + const hasOnlyOneEditor = + enrichedAccesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1; const getAccessUser = (mailbox: MailboxLight) => ({ ...mailbox, - full_name: mailbox.name + full_name: mailbox.name, }); - const searchResults = searchMailboxesQuery.data?.data.filter((mailbox) => !accesses.some(a => a.mailbox.id === mailbox.id)).map(getAccessUser) ?? []; - const hasOnlyOneEditor = accesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1; - const canManageThreadAccess = useAbility(Abilities.CAN_MANAGE_THREAD_ACCESS, [selectedMailbox!, selectedThread!]); - const normalizedAccesses = accesses.map((access) => ({ + const searchResults = searchMailboxesQuery.data?.data + .filter((mailbox) => !enrichedAccesses.some((a) => a.mailbox.id === mailbox.id)) + .map(getAccessUser) ?? []; + + const normalizedAccesses = enrichedAccesses.map((access) => ({ ...access, user: getAccessUser(access.mailbox), - can_delete: canManageThreadAccess && accesses.length > 1 && (!hasOnlyOneEditor || access.role !== ThreadAccessRoleChoices.editor), + can_delete: canManageThreadAccess && enrichedAccesses.length > 1 && (!hasOnlyOneEditor || access.role !== ThreadAccessRoleChoices.editor), })); + const accessRoleOptions = (isDisabled?: boolean) => + Object.values(ThreadAccessRoleChoices).map((role) => ({ + label: t(`thread_roles_${role}`, { ns: 'roles' }), + value: role, + isDisabled: isDisabled ?? (hasOnlyOneEditor && role !== ThreadAccessRoleChoices.editor), + })); + const handleCreateAccesses = (mailboxes: MailboxLight[], role: string) => { const mailboxIds = [...new Set(mailboxes.map((m) => m.id))]; mailboxIds.forEach((mailboxId) => { - createThreadAccess({ + createMutation.mutate({ threadId: selectedThread!.id, data: { thread: selectedThread!.id, mailbox: mailboxId, role: role as ThreadAccessRoleChoices, - } + }, }); }); - } + }; - const handleUpdateAccess = (access: ThreadAccessDetail, role: string) => { - updateThreadAccess({ + const handleUpdateAccess = (access: EnrichedAccess, role: string) => { + updateMutation.mutate({ id: access.id, threadId: selectedThread!.id, data: { thread: selectedThread!.id, mailbox: access.mailbox.id, role: role as ThreadAccessRoleChoices, - } + }, }); - } + }; - const handleDeleteAccess = async (access: ThreadAccessDetail) => { - // TODO : Update Share Modal to hide the remove button if there is only one editor + const handleDeleteAccess = async (access: EnrichedAccess) => { if (hasOnlyOneEditor && access.role === ThreadAccessRoleChoices.editor) { - addToast( -

{t('You cannot delete the last editor of this thread')}

-
, { - toastId: "last-editor-deletion-forbidden", - autoClose: 3000, - }); + addToast( + +

{t('You cannot delete the last editor of this thread')}

+
, + { toastId: "last-editor-deletion-forbidden", autoClose: 3000 }, + ); return; - }; + } const isSelfRemoval = access.mailbox.id === selectedMailbox?.id; const decision = await modals.deleteConfirmationModal({ title: isSelfRemoval ? t('Leave this thread?') : t('Remove access?'), children: isSelfRemoval ? t( 'You and all users with access to the mailbox "{{mailboxName}}" will no longer see this thread.', - { mailboxName: access.mailbox.email } + { mailboxName: access.mailbox.email }, ) : t( 'All users with access to the mailbox "{{mailboxName}}" will no longer see this thread.', - { mailboxName: access.mailbox.email } + { mailboxName: access.mailbox.email }, ), }); if (decision !== 'delete') return; - removeThreadAccess({ + removeMutation.mutate({ id: access.id, - threadId: selectedThread!.id + threadId: selectedThread!.id, }, { onSuccess: () => { - addToast( -

{t('Thread access removed')}

-
); + addToast( + +

{t('Thread access removed')}

+
, + ); if (isSelfRemoval) { setIsShareModalOpen(false); invalidateThreadMessages({ @@ -122,17 +286,44 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) => invalidateThreadsStats(); unselectThread(); } - } + }, }); - } + }; - const accessRoleOptions = (isDisabled?: boolean) => Object.values(ThreadAccessRoleChoices).map((role) => { - return { - label: t(`thread_roles_${role}`, { ns: 'roles' }), - value: role, - isDisabled: isDisabled ?? (hasOnlyOneEditor && role !== ThreadAccessRoleChoices.editor), + const handleAssignUser = async (user: UserWithoutAbilities, access: EnrichedAccess) => { + if (access.role === ThreadAccessRoleChoices.viewer) { + const decision = await modals.confirmationModal({ + title: {t('Grant editor access to the thread?')}, + children: ( + + {t( + 'The mailbox "{{mailbox}}" currently has read-only access on this thread. To assign {{user}} to it, edit permissions must be granted to this mailbox.', + { mailbox: access.mailbox.email, user: user.full_name || user.email || "" }, + )} + + ), + }); + if (decision !== 'yes') return; + markUserMutating(user.id); + try { + await updateMutation.mutateAsync({ + id: access.id, + threadId: selectedThread!.id, + data: { + thread: selectedThread!.id, + mailbox: access.mailbox.id, + role: ThreadAccessRoleChoices.editor, + }, + }); + } catch { + clearUserMutating(user.id); + return; + } + } else { + markUserMutating(user.id); } - }); + dispatchAssignEvent(user, { onSettled: () => clearUserMutating(user.id) }); + }; return ( <> @@ -148,26 +339,142 @@ export const ThreadAccessesWidget = ({ accesses }: ThreadAccessesWidgetProps) => {accesses.length} - - modalTitle={t('Share access')} + + modalTitle={isAssignmentContext ? t('Share and assign the thread') : t('Share the thread')} isOpen={isShareModalOpen} - loading={searchMailboxesQuery.isLoading} + loading={searchMailboxesQuery.isLoading || threadAccessesQuery.isLoading} canUpdate={canManageThreadAccess} onClose={() => setIsShareModalOpen(false)} invitationRoles={accessRoleOptions(false)} getAccessRoles={() => accessRoleOptions()} onInviteUser={handleCreateAccesses} onUpdateAccess={handleUpdateAccess} - onDeleteAccess={accesses.length > 1 ? handleDeleteAccess : undefined} + onDeleteAccess={enrichedAccesses.length > 1 ? handleDeleteAccess : undefined} onSearchUsers={setSearchQuery} + searchPlaceholder={t('Search a mailbox to share this thread with')} + searchGroupName={t('Search results')} searchUsersResult={searchResults} accesses={normalizedAccesses} + allowInvitation={false} + getAccessClassName={(access) => { + // The mailbox row is rendered by upstream ShareMemberItem + // so we can't wrap the avatar ourselves — CSS hooks into + // these classes descendant-style. + // --shared: non-identity mailbox (alias / group) + // → square-rounded avatar to signal "team". + // --assigned: single-user identity mailbox whose user + // is assigned to the thread → brand ring. + const classes: string[] = []; + if (access.mailbox.is_identity === false) { + classes.push("share-modal-extensions__share-member-item--shared"); + } + if ( + isAssignmentContext && + access.users?.length === 1 && + access.mailbox.is_identity !== false && + assignedUserIds.has(access.users[0].id) + ) { + classes.push("share-modal-extensions__share-member-item--assigned"); + } + return classes.length ? classes.join(" ") : undefined; + }} + membersTitle={(members) => { + const sharedCount = members.filter( + (m) => (m.users?.length ?? 0) > 1 || m.mailbox.is_identity === false, + ).length; + const total = t('Shared between {{count}} mailboxes', { + count: members.length, + defaultValue_one: 'Shared between {{count}} mailbox', + }); + if (sharedCount === 0) { + return total; + } + const shared = t('{{count}} of which are shared', { + count: sharedCount, + defaultValue: ', {{count}} of which are shared', + defaultValue_one: ', {{count}} of which is shared', + }); + return `${total}${shared}`; + }} accessRoleTopMessage={(access) => { if (hasOnlyOneEditor && access.role === ThreadAccessRoleChoices.editor) { return t('You are the last editor of this thread, you cannot therefore modify your access.'); } + return undefined; + }} + renderAccessFooter={(access) => ( + + )} + renderAccessRightExtras={(access) => { + // Inline spinner when a mutation is in flight on this + // specific access row (role change or removal) — the + // ShareModal's own select can't convey "pending" state. + if (isAccessPending(access.id) || isMailboxPending(access.mailbox.id)) { + return ; + } + // For single-user identity mailboxes, the mailbox row + // already shows the user's name and email — duplicating + // it in a one-line sub-list would be pure noise. Render + // the assignment toggle inline next to the role select + // instead. Multi-user or non-identity mailboxes fall + // through to AccessUsersList. + if (!isAssignmentContext) return null; + if (!access.users || access.users.length !== 1) return null; + if (access.mailbox.is_identity === false) return null; + const user = access.users[0]; + if (!canManageThreadAccess) return null; + if (assignedUserIds.has(user.id)) { + return ( + + ); + } + const isMutating = mutatingUserIds.has(user.id); + return ( + + ); }} - /> + outsideSearchContent={( +
+ +
+ )} + > + {isAssignmentContext && ( + + )} + - ) -} + ); +}); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss index 0623047f5..e90023512 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/_index.scss @@ -1,3 +1,10 @@ +.thread-action-bar__container { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--c--globals--spacings--xs); +} + .thread-action-bar { display: flex; flex-direction: row; @@ -20,3 +27,8 @@ align-items: center; gap: var(--c--globals--spacings--4xs); } + +// Hide thread action bar if empty +.thread-action-bar:not(:has(*)) { + display: none; +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx index 4d056baca..781393876 100644 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-action-bar/index.tsx @@ -4,9 +4,10 @@ import useTrash from "@/features/message/use-trash"; import useAbility, { Abilities } from "@/hooks/use-ability"; import { DropdownMenu, Icon, IconType, VerticalSeparator } from "@gouvfr-lasuite/ui-kit" import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react" -import { useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { ThreadAccessesWidget } from "../thread-accesses-widget"; +import { ThreadAccessesWidget, type ThreadAccessesWidgetHandle } from "../thread-accesses-widget"; +import { AssigneesWidget } from "../assignees-widget"; import { LabelsWidget } from "@/features/layouts/components/labels-widget"; import useArchive from "@/features/message/use-archive"; import useSpam from "@/features/message/use-spam"; @@ -29,6 +30,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr const { markAsStarred, markAsUnstarred } = useStarred(); const { mutate: removeThreadAccess } = useThreadsAccessesDestroy(); const modals = useModals(); + const accessesWidgetRef = useRef(null); // Full edit rights on the thread — gates archive, spam, delete. // Star and "mark as unread" remain visible because they are personal // state on the user's ThreadAccess (read_at / starred_at). @@ -68,9 +70,13 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr }); }; - return ( + return ( +
+
+ accessesWidgetRef.current?.open()} /> +
- +
+
+ ) } diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/_index.scss index cac9026dc..453c602bb 100755 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/_index.scss @@ -119,3 +119,14 @@ color: var(--c--contextuals--content--semantic--neutral--tertiary); } } + +.thread-event-input__suggestion { + display: flex; + align-items: center; + gap: var(--c--globals--spacings--xs); + + & > :first-child { + flex: 1; + min-width: 0; + } +} diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/index.tsx index 2f1987e8c..0c43ea670 100755 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event-input/index.tsx @@ -1,10 +1,11 @@ import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Icon, IconSize, IconType, UserRow } from "@gouvfr-lasuite/ui-kit"; -import { useThreadsEventsCreate, useThreadsEventsPartialUpdate, useThreadsUsersList, UserWithoutAbilities, ThreadEventTypeEnum, ThreadEvent } from "@/features/api/gen"; +import { useThreadsEventsCreate, useThreadsEventsPartialUpdate, useThreadsUsersList, ThreadMentionableUser, ThreadEventTypeEnum, ThreadEvent, ThreadEventIMData } from "@/features/api/gen"; import { StringHelper } from "@/features/utils/string-helper"; import { TextHelper } from "@/features/utils/text-helper"; import { Button } from "@gouvfr-lasuite/cunningham-react"; +import { Badge } from "@/features/ui/components/badge"; import { useMailboxContext } from "@/features/providers/mailbox"; import { useAuth } from "@/features/auth"; import { SuggestionInput } from "@/features/ui/components/suggestion-input"; @@ -57,7 +58,7 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent const users = usersData?.data ?? []; const mentionedIds = new Set(mentions.map((m) => m.id)); - const filteredUsers = users.filter((user: UserWithoutAbilities) => { + const filteredUsers = users.filter((user: ThreadMentionableUser) => { if (user.id === currentUser?.id) return false; if (mentionedIds.has(user.id)) return false; if (!mentionFilter) return true; @@ -145,7 +146,7 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent onCancelEdit?.(); }, [resetInput, onCancelEdit]); - const insertMention = (user: UserWithoutAbilities) => { + const insertMention = (user: ThreadMentionableUser) => { const name = user.full_name || user.email || ""; if (!mentions.some((m) => m.id === user.id)) { @@ -216,9 +217,10 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent // Populate input when entering edit mode, reset when leaving useEffect(() => { if (editingEvent) { - const eventContent = editingEvent?.data?.content ?? ""; + const eventData = editingEvent?.data as ThreadEventIMData | null; + const eventContent = eventData?.content ?? ""; // Restore mentions from persisted data.mentions (contains real user IDs) - const persistedMentions = editingEvent?.data?.mentions ?? []; + const persistedMentions = eventData?.mentions ?? []; // Build a lookup to map name → id from persisted mentions const mentionsByName = new Map(persistedMentions.map((m) => [m.name, m.id])); // Convert @[Name] to @Name for display, rebuild mentions list with real IDs @@ -292,10 +294,17 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent itemToString={(item) => item?.full_name || item?.email || ""} keyExtractor={(user) => user.id} renderItem={(user) => ( - +
+ + {!user.can_post_comments && ( + + {t("Read-only")} + + )} +
)} /> + {expanded && ( +
+ {events.map((evt) => ( + + ))} +
+ )} + + ); +}; diff --git a/src/frontend/src/features/layouts/components/thread-view/index.tsx b/src/frontend/src/features/layouts/components/thread-view/index.tsx index 7418e561a..ee1f404cc 100755 --- a/src/frontend/src/features/layouts/components/thread-view/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/index.tsx @@ -2,12 +2,13 @@ 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 { ThreadEvent, isCondensed } from "./components/thread-event" +import { ThreadEvent, isCondensed, groupSystemEvents, CollapsedEventsGroup } 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 useMentionRead from "@/features/message/use-mention-read" import { useDebounceCallback } from "@/hooks/use-debounce-callback" +import { useIsSharedContext } from "@/hooks/use-is-shared-context" import { useVisibilityObserver } from "@/hooks/use-visibility-observer" import { MailboxRoleChoices, Message, Thread, ThreadEvent as ThreadEventModel } from "@/features/api/gen/models" import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit" @@ -61,6 +62,11 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag // Refs for thread events with unread mentions const mentionRefs = useRef>({}); const { markMentionsRead } = useMentionRead(thread.id); + // Collapse runs of 3+ consecutive non-IM events between messages/IMs + // behind a progressive-disclosure toggle. Short runs stay inline so we + // don't hide metadata when there's little of it — pattern borrowed from + // Linear's collapsed issue history. + const renderItems = useMemo(() => groupSystemEvents(threadItems), [threadItems]); // Find all unread message IDs 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]); @@ -288,11 +294,19 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag )} )} - {threadItems.map((item, index) => { - if (isThreadEvent(item)) { - const prevItem = index > 0 ? threadItems[index - 1] : null; - const prevEvent = isThreadEvent(prevItem) ? prevItem.data : null; - const eventData = item.data as ThreadEventModel; + {renderItems.map((item, index) => { + if (item.kind === 'collapsed_events') { + return ( + + ); + } + if (item.kind === 'event') { + const prevItem = index > 0 ? renderItems[index - 1] : null; + const prevEvent = prevItem?.kind === 'event' ? prevItem.data : null; + const eventData = item.data; return ( { }), [messagesWithDraftChildren]); // Show IM input when the user has at least edit rights on the mailbox // (editor/sender/admin), regardless of the thread-level role. - // Still gated to shared contexts: either a shared mailbox or a thread - // shared across multiple mailboxes. + // Still gated to shared contexts (see useIsSharedContext). const hasMailboxEditAccess = !!selectedMailbox && ( selectedMailbox.role === MailboxRoleChoices.editor || selectedMailbox.role === MailboxRoleChoices.sender || selectedMailbox.role === MailboxRoleChoices.admin ); - const hasMultipleAccesses = (selectedThread?.accesses?.length ?? 0) > 1; - const isSharedMailbox = selectedMailbox?.is_identity === false; - const showIMInput = Boolean((isSharedMailbox || hasMultipleAccesses) && hasMailboxEditAccess); + const isSharedContext = useIsSharedContext(); + const showIMInput = Boolean(isSharedContext && hasMailboxEditAccess); // Build filtered timeline items: enrich messages with draft children, // apply trash filtering, and keep all events. diff --git a/src/frontend/src/features/message/use-assigned-users.ts b/src/frontend/src/features/message/use-assigned-users.ts new file mode 100644 index 000000000..fd3a615a6 --- /dev/null +++ b/src/frontend/src/features/message/use-assigned-users.ts @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import { ThreadEventTypeEnum } from "@/features/api/gen"; +import { useMailboxContext } from "@/features/providers/mailbox"; + +export type AssignedUser = { + id: string; + name: string; +}; + +/** + * Derive the current set of assigned users from the thread event history. + * + * Events arrive in chronological order (oldest first). We walk the list + * in reverse and keep the most recent ASSIGN/UNASSIGN decision per user, + * because assignment state is event-sourced (no denormalized field on Thread). + */ +export const useAssignedUsers = (): AssignedUser[] => { + const { threadEvents } = useMailboxContext(); + + return useMemo(() => { + if (!threadEvents || threadEvents.length === 0) return []; + + const resolved = new Set(); + const assigned: AssignedUser[] = []; + + for (let i = threadEvents.length - 1; i >= 0; i--) { + const event = threadEvents[i]; + if ( + event.type !== ThreadEventTypeEnum.assign && + event.type !== ThreadEventTypeEnum.unassign + ) { + continue; + } + const data = event.data; + if (!("assignees" in data)) continue; + + const assignees = data.assignees as ReadonlyArray; + for (const assignee of assignees) { + if (resolved.has(assignee.id)) continue; + resolved.add(assignee.id); + if (event.type === ThreadEventTypeEnum.assign) { + assigned.push(assignee); + } + } + } + + return assigned; + }, [threadEvents]); +}; diff --git a/src/frontend/src/features/message/use-thread-assignment.ts b/src/frontend/src/features/message/use-thread-assignment.ts new file mode 100644 index 000000000..cff58180c --- /dev/null +++ b/src/frontend/src/features/message/use-thread-assignment.ts @@ -0,0 +1,163 @@ +import { useMemo, useState } from "react"; +import { + ThreadEventTypeEnum, + useThreadsEventsCreate, +} from "@/features/api/gen"; +import { useMailboxContext } from "@/features/providers/mailbox"; +import { useAssignedUsers } from "./use-assigned-users"; + +export type AssignableUser = { + id: string; + full_name?: string | null; + email?: string | null; +}; + +export type UseThreadAssignmentResult = { + assignedUserIds: ReadonlySet; + /** + * IDs of users with an assign/unassign mutation in flight. A Set + * (rather than a single scalar) is required because the UI can fire + * concurrent toggles — tracking only the latest id would clear the + * spinner / disabled state for an earlier still-pending request when + * the next one settles. + */ + mutatingUserIds: ReadonlySet; + /** + * Mark a user as having a mutation in flight. Pair with + * `clearUserMutating(id)` once the mutation settles. + */ + markUserMutating: (id: string) => void; + clearUserMutating: (id: string) => void; + /** + * High-level helper: marks the user as pending and fires the assign + * event. Use for simple flows where the caller has nothing to do + * before/after the mutation. + */ + assignUser: (user: AssignableUser) => void; + /** + * Low-level helper: just fires the assign event without touching the + * pending set. Caller is responsible for marking/clearing the user + * — required when the assignment is preceded by an extra step (e.g. + * promoting a viewer mailbox to editor) that must also block the UI. + */ + dispatchAssignEvent: ( + user: AssignableUser, + options?: { onSettled?: () => void }, + ) => void; + unassignUser: (userId: string) => void; +}; + +/** + * Centralizes assign / unassign mutations against the currently selected + * thread. Wraps `useThreadsEventsCreate` and the post-mutation cache + * invalidation, and tracks the set of in-flight user ids so the UI can + * disable affordances and show per-row spinners — even when several + * toggles are pending concurrently. + */ +export const useThreadAssignment = (): UseThreadAssignmentResult => { + const { + selectedThread, + invalidateThreadEvents, + invalidateThreadsList, + invalidateThreadsStats, + } = useMailboxContext(); + const assignedUsers = useAssignedUsers(); + const [mutatingUserIds, setMutatingUserIds] = useState>( + () => new Set(), + ); + const markUserMutating = (id: string) => { + setMutatingUserIds((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + }; + const clearUserMutating = (id: string) => { + setMutatingUserIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + const { mutate: createThreadEvent } = useThreadsEventsCreate(); + + // Stable reference: consumers diff this Set across renders to detect + // server-confirmed assignment changes, so it must only change when the + // underlying assigned users actually change. + const assignedUserIds = useMemo( + () => new Set(assignedUsers.map((u) => u.id)), + [assignedUsers], + ); + + // Single code path for assign + unassign: both fire the same + // `ThreadEvent` shape (only the `type` and the assignee payload differ) + // and share the exact same post-mutation invalidations. + const fireAssignmentEvent = ( + type: typeof ThreadEventTypeEnum.assign | typeof ThreadEventTypeEnum.unassign, + assignee: { id: string; name: string }, + options?: { onSettled?: () => void }, + ) => { + if (!selectedThread?.id) { + options?.onSettled?.(); + return; + } + createThreadEvent( + { + threadId: selectedThread.id, + data: { + type, + data: { assignees: [assignee] }, + }, + }, + { + onSuccess: async () => { + await invalidateThreadEvents(); + await invalidateThreadsList(); + await invalidateThreadsStats(); + }, + onSettled: () => options?.onSettled?.(), + }, + ); + }; + + const dispatchAssignEvent = ( + user: AssignableUser, + options?: { onSettled?: () => void }, + ) => { + fireAssignmentEvent( + ThreadEventTypeEnum.assign, + { id: user.id, name: user.full_name || user.email || "" }, + options, + ); + }; + + const assignUser = (user: AssignableUser) => { + markUserMutating(user.id); + dispatchAssignEvent(user, { + onSettled: () => clearUserMutating(user.id), + }); + }; + + const unassignUser = (userId: string) => { + const target = assignedUsers.find((u) => u.id === userId); + if (!target) return; + markUserMutating(userId); + fireAssignmentEvent( + ThreadEventTypeEnum.unassign, + { id: target.id, name: target.name }, + { onSettled: () => clearUserMutating(userId) }, + ); + }; + + return { + assignedUserIds, + mutatingUserIds, + markUserMutating, + clearUserMutating, + assignUser, + dispatchAssignEvent, + unassignUser, + }; +}; diff --git a/src/frontend/src/features/providers/mailbox.tsx b/src/frontend/src/features/providers/mailbox.tsx index 174708a0b..685863f85 100644 --- a/src/frontend/src/features/providers/mailbox.tsx +++ b/src/frontend/src/features/providers/mailbox.tsx @@ -52,6 +52,7 @@ type MailboxContextType = { loadNextThreads: () => Promise; invalidateThreadMessages: (source?: MessageQueryInvalidationSource) => Promise; invalidateThreadEvents: () => Promise; + invalidateThreadsList: () => Promise; invalidateThreadsStats: () => Promise; invalidateLabels: () => Promise; refetchMailboxes: (options?: RefetchOptions) => Promise; @@ -159,6 +160,7 @@ const MailboxContext = createContext({ unselectThread: () => {}, invalidateThreadMessages: async () => {}, invalidateThreadEvents: async () => {}, + invalidateThreadsList: async () => {}, invalidateThreadsStats: async () => {}, invalidateLabels: async () => {}, refetchMailboxes: async () => {}, @@ -589,6 +591,12 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { } } + const invalidateThreadsList = async () => { + await queryClient.invalidateQueries({ + queryKey: getMailboxThreadsListQueryKeyPrefix(selectedMailbox?.id), + }); + } + const invalidateThreadsStats = async () => { await queryClient.invalidateQueries({ queryKey: getThreadsStatsQueryKey(selectedMailbox?.id), @@ -629,6 +637,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => { loadNextThreads: threadsQuery.fetchNextPage, invalidateThreadMessages, invalidateThreadEvents, + invalidateThreadsList, invalidateThreadsStats, invalidateLabels, refetchMailboxes: mailboxQuery.refetch, diff --git a/src/frontend/src/features/ui/components/assignees-avatar-group/_index.scss b/src/frontend/src/features/ui/components/assignees-avatar-group/_index.scss new file mode 100644 index 000000000..8b694ee40 --- /dev/null +++ b/src/frontend/src/features/ui/components/assignees-avatar-group/_index.scss @@ -0,0 +1,54 @@ +.assignees-avatar-group { + display: inline-flex; + align-items: center; + + > * + * { + margin-left: -6px; + } +} + +.assignees-avatar-group__overflow { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--c--contextuals--background--semantic--neutral--secondary); + color: var(--c--contextuals--content--semantic--neutral--secondary); + border: 1px solid color-mix(in srgb, var(--c--contextuals--background--surface--tertiary) 80%, transparent); + font-weight: 700; + font-family: var(--c--globals--font--families--base); + line-height: 1; + box-sizing: border-box; + padding: 0 var(--c--globals--spacings--4xs); +} + +// Keep each size in lock-step with the matching @gouvfr-lasuite/ui-kit +// .c__avatar. rule so the overflow chip has the same footprint +// (and font metrics) as the UserAvatars it sits next to. +.assignees-avatar-group[data-size="xsmall"] .assignees-avatar-group__overflow { + min-width: 16px; + height: 16px; + font-size: 7px; + letter-spacing: -0.25px; +} + +.assignees-avatar-group[data-size="small"] .assignees-avatar-group__overflow { + min-width: 24px; + height: 24px; + font-size: 10px; + letter-spacing: -0.35px; +} + +.assignees-avatar-group[data-size="medium"] .assignees-avatar-group__overflow { + min-width: 32px; + height: 32px; + font-size: 11px; + letter-spacing: -0.38px; +} + +.assignees-avatar-group[data-size="large"] .assignees-avatar-group__overflow { + min-width: 64px; + height: 64px; + font-size: 18px; + letter-spacing: -0.54px; +} diff --git a/src/frontend/src/features/ui/components/assignees-avatar-group/index.test.tsx b/src/frontend/src/features/ui/components/assignees-avatar-group/index.test.tsx new file mode 100644 index 000000000..694a9736f --- /dev/null +++ b/src/frontend/src/features/ui/components/assignees-avatar-group/index.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AssigneesAvatarGroup, type AssigneesAvatarGroupUser } from "./index"; + +vi.mock("@gouvfr-lasuite/ui-kit", () => ({ + UserAvatar: ({ fullName }: { fullName: string }) => ( + {fullName} + ), +})); + +const makeUsers = (count: number): AssigneesAvatarGroupUser[] => + Array.from({ length: count }, (_, i) => ({ + id: `id-${i}`, + name: `User ${i}`, + })); + +describe("AssigneesAvatarGroup", () => { + it("renders nothing when the list is empty", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); + + it("exposes the size via data-size so the overflow circle can mirror avatar dimensions", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-size="small"'); + }); + + describe("default (extra) overflow mode", () => { + it("shows all avatars without overflow counter when within the cap", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-testid="avatar"/g)).toHaveLength(3); + expect(html).not.toContain("assignees-avatar-group__overflow"); + }); + + it("caps avatars at maxAvatars and appends the overflow counter", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-testid="avatar"/g)).toHaveLength(3); + expect(html).toContain( + '', + ); + }); + }); + + describe("replace-last overflow mode", () => { + it("shows all avatars when within the cap", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-testid="avatar"/g)).toHaveLength(2); + expect(html).not.toContain("assignees-avatar-group__overflow"); + }); + + it("replaces the last avatar with the overflow counter", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-testid="avatar"/g)).toHaveLength(1); + expect(html).toContain( + '', + ); + }); + + it("still shows one avatar + counter when exactly (maxAvatars+1)", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-testid="avatar"/g)).toHaveLength(1); + expect(html).toContain("+2"); + }); + }); +}); diff --git a/src/frontend/src/features/ui/components/assignees-avatar-group/index.tsx b/src/frontend/src/features/ui/components/assignees-avatar-group/index.tsx new file mode 100644 index 000000000..3f0fdfcd7 --- /dev/null +++ b/src/frontend/src/features/ui/components/assignees-avatar-group/index.tsx @@ -0,0 +1,59 @@ +import { UserAvatar } from "@gouvfr-lasuite/ui-kit"; + +export type AssigneesAvatarGroupUser = { + id: string; + name: string; +}; + +export type AssigneesAvatarGroupOverflowMode = "extra" | "replace-last"; + +export type AssigneesAvatarGroupSize = "xsmall" | "small" | "medium" | "large"; + +type AssigneesAvatarGroupProps = { + users: ReadonlyArray; + maxAvatars: number; + overflowMode?: AssigneesAvatarGroupOverflowMode; + size?: AssigneesAvatarGroupSize; +}; + +/** + * Stack of overlapping avatars for a list of assignees, with an overflow + * counter when the list exceeds ``maxAvatars``. + * + * Overflow modes: + * - ``"extra"`` (default): counter appears *after* ``maxAvatars`` avatars + * — used where space is generous (e.g. thread header widget). + * - ``"replace-last"``: counter replaces the last avatar slot so the total + * visible slot count never exceeds ``maxAvatars`` — used in compact + * contexts like the thread list row. + * + * The parent owns any surrounding interactive wrapper (tooltip, button...). + */ +export const AssigneesAvatarGroup = ({ + users, + maxAvatars, + overflowMode = "extra", + size = "xsmall", +}: AssigneesAvatarGroupProps) => { + if (users.length === 0) return null; + + const hasOverflow = users.length > maxAvatars; + const avatarCount = hasOverflow && overflowMode === "replace-last" + ? Math.max(maxAvatars - 1, 0) + : Math.min(users.length, maxAvatars); + const visible = users.slice(0, avatarCount); + const overflow = users.length - avatarCount; + + return ( + + {visible.map((user) => ( + + ))} + {overflow > 0 && ( + + )} + + ); +}; diff --git a/src/frontend/src/features/utils/date-helper.ts b/src/frontend/src/features/utils/date-helper.ts index 72a457f61..4d09c819e 100644 --- a/src/frontend/src/features/utils/date-helper.ts +++ b/src/frontend/src/features/utils/date-helper.ts @@ -43,6 +43,36 @@ export class DateHelper { return format(date, 'dd/MM/yyyy', { locale: dateLocale }); } + /** + * Bucket a date into one of three coarse ranges used by the thread-view + * timeline to label collapsed runs of system events. + */ + public static bucketDate(dateString: string | Date): 'today' | 'this_week' | 'older' { + const date = typeof dateString === 'string' ? new Date(dateString) : dateString; + if (isToday(date)) return 'today'; + if (isSameWeek(date, Date.now())) return 'this_week'; + return 'older'; + } + + /** + * Format a timestamp for inline event display: always includes the time, + * prefixed with the day/date for events outside of today so a user can tell + * whether an assignment happened today at 14:32 or last Tuesday at 14:32 + * without hovering. + */ + public static formatEventTimestamp(dateString: string, lng: string = 'en'): string { + const date = new Date(dateString); + const locale = lng.length > 2 ? lng.split('-')[0] : lng; + const dateLocale = locales[locale as keyof typeof locales]; + const time = format(date, 'HH:mm', { locale: dateLocale }); + + if (isToday(date)) return time; + if (isYesterday(date)) return `${i18n.t('Yesterday')} ${time}`; + if (isSameWeek(date, Date.now())) return `${format(date, 'EEEE', { locale: dateLocale })} ${time}`; + if (isSameYear(date, Date.now())) return `${format(date, 'd MMM', { locale: dateLocale })} ${time}`; + return `${format(date, 'dd/MM/yyyy', { locale: dateLocale })} ${time}`; + } + /** * Compute a relative time between a given date and a time reference and * return a translation key and a count if needed. diff --git a/src/frontend/src/hooks/use-is-shared-context.ts b/src/frontend/src/hooks/use-is-shared-context.ts new file mode 100644 index 000000000..fb143c624 --- /dev/null +++ b/src/frontend/src/hooks/use-is-shared-context.ts @@ -0,0 +1,16 @@ +import { useMailboxContext } from "@/features/providers/mailbox"; + +/** + * True when the current thread/mailbox pair is collaborative: shared + * mailbox (Mailbox.is_shared — non-identity or identity shared via + * delegation) or thread accessible from more than one mailbox. Gates + * thread-scoped collaboration features (assignment CTAs, internal + * messages) that have no purpose in a mono-user conversation. + */ +export const useIsSharedContext = (): boolean => { + const { selectedMailbox, selectedThread } = useMailboxContext(); + return ( + selectedMailbox?.is_shared === true + || (selectedThread?.accesses?.length ?? 0) > 1 + ); +}; diff --git a/src/frontend/src/styles/main.scss b/src/frontend/src/styles/main.scss index ad9594df2..01a7f0284 100644 --- a/src/frontend/src/styles/main.scss +++ b/src/frontend/src/styles/main.scss @@ -27,6 +27,7 @@ @use "./../features/ui/components/progress-bar"; @use "./../features/ui/components/circular-progress"; @use "./../features/ui/components/suggestion-input"; +@use "./../features/ui/components/assignees-avatar-group"; @use "./../features/layouts/components/mailbox-panel"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-actions"; @use "./../features/layouts/components/mailbox-panel/components/mailbox-list"; @@ -44,6 +45,9 @@ @use "./../features/layouts/components/thread-view/components/thread-attachment-list"; @use "./../features/layouts/components/thread-view/components/calendar-invite"; @use "./../features/layouts/components/thread-view/components/thread-accesses-widget"; +@use "./../features/layouts/components/thread-view/components/share-modal-extensions"; +@use "./../features/layouts/components/thread-view/components/assignees-widget"; +@use "./../features/layouts/components/thread-view/components/assignees-widget/quick-assign-popover"; @use "./../features/forms/components/message-form"; @use "./../features/forms/components/search-input"; @use "./../features/forms/components/search-filters-form";