mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
fixup! ✨(global) allow thread assignation
This commit is contained in:
@@ -1,446 +1,120 @@
|
||||
# Permissions & Abilities
|
||||
|
||||
Documentation de la logique d'autorisation dans Messages : modèle de données, rôles, mécanismes techniques, matrice des droits par ressource et état des lieux.
|
||||
# Permissions & Data Model
|
||||
|
||||
> Public visé : développeur·euse backend intervenant sur l'API REST ou sur le domaine d'accès.
|
||||
## Core Data Model
|
||||
|
||||
---
|
||||
### Entity Relationships
|
||||
|
||||
## 1. Concepts fondamentaux
|
||||
|
||||
### 1.1 Modèle à deux niveaux
|
||||
|
||||
Messages applique une autorisation à deux niveaux, indépendants mais composés :
|
||||
|
||||
```
|
||||
Utilisateur ──(MailboxAccess)──▶ Mailbox ──(ThreadAccess)──▶ Thread
|
||||
│
|
||||
└──(possède)──▶ Label, Contact, Channel…
|
||||
|
||||
Utilisateur ──(MailDomainAccess)──▶ MailDomain ──(possède)──▶ Mailbox
|
||||
```text
|
||||
User
|
||||
├── MailboxAccess (role: VIEWER|EDITOR|SENDER|ADMIN)
|
||||
│ └── Mailbox
|
||||
│ ├── ThreadAccess (role: VIEWER|EDITOR)
|
||||
│ │ └── Thread
|
||||
│ │ ├── Message
|
||||
│ │ │ ├── sender → Contact
|
||||
│ │ │ ├── recipients → MessageRecipient → Contact
|
||||
│ │ │ ├── parent → Message (reply chain)
|
||||
│ │ │ ├── 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)
|
||||
```
|
||||
|
||||
- **`MailboxAccess`** : rôle de l'utilisateur **sur une BAL** (boîte aux lettres). Porte les rôles `VIEWER`, `EDITOR`, `SENDER`, `ADMIN`.
|
||||
- **`ThreadAccess`** : rôle d'une **BAL sur un fil**. Porte les rôles `VIEWER`, `EDITOR`. C'est ce qui permet de partager un fil à plusieurs BAL avec des niveaux différents.
|
||||
- **`MailDomainAccess`** : rôle (unique : `ADMIN`) d'un utilisateur sur un domaine de messagerie.
|
||||
### Key Models
|
||||
|
||||
Cette séparation est volontaire : un utilisateur `SENDER` sur une BAL dispose des droits d'envoi pour cette BAL, mais si cette BAL n'a qu'un accès `VIEWER` sur un fil donné, l'utilisateur ne peut pas en modifier l'état partagé. C'est la source de vérité portée par le queryset `ThreadAccess.objects.editable_by(user)` (voir §3.4).
|
||||
| Model | Purpose | Key Fields |
|
||||
|-------|---------|------------|
|
||||
| **User** | Identity (OIDC) | `sub`, `email`, `full_name` |
|
||||
| **Mailbox** | Email account | `local_part`, `domain` (FK) |
|
||||
| **MailboxAccess** | User→Mailbox permission | `user`, `mailbox`, `role` (unique together) |
|
||||
| **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) |
|
||||
|
||||
### 1.2 Règle d'or des « droits d'édition pleins » sur un fil
|
||||
## Role Hierarchies
|
||||
|
||||
Pour **muter l'état partagé** d'un fil (archiver, corbeille, spam, labels, événements non-IM, partage), il faut **cumulativement** :
|
||||
### MailboxRoleChoices (User access to Mailbox)
|
||||
|
||||
1. `ThreadAccess.role == EDITOR` sur une mailbox partagée du fil ;
|
||||
2. `MailboxAccess.role ∈ {EDITOR, SENDER, ADMIN}` sur cette même mailbox.
|
||||
|
||||
Cette contrainte évite qu'un `VIEWER` sur une BAL collective puisse muter un fil auquel la BAL a pourtant un `EDITOR` ThreadAccess.
|
||||
|
||||
### 1.3 Actions personnelles vs actions partagées
|
||||
|
||||
Certaines actions ne modifient **que l'état personnel** de l'utilisateur et sont autorisées aux `VIEWER` :
|
||||
|
||||
| Action personnelle | Donnée mutée | Accès minimum |
|
||||
|---|---|---|
|
||||
| Marquer lu / non lu | `ThreadAccess.read_at` | `MailboxAccess` quelconque |
|
||||
| Starred / unstarred | `ThreadAccess.starred_at` | `MailboxAccess` quelconque |
|
||||
| Lire une mention | `UserEvent.read_at` (pour soi) | Accès au fil |
|
||||
| Poster un commentaire interne (`IM`) | Nouveau `ThreadEvent` | `ThreadAccess` + `MailboxAccess` au moins `EDITOR` sur la BAL |
|
||||
|
||||
Les flags de message (`trashed`, `archived`, `spam`) sont au contraire un **état partagé** et exigent les droits d'édition pleins.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rôles et enums
|
||||
|
||||
Les enums sont définis dans `src/backend/core/enums.py`.
|
||||
|
||||
### 2.1 `MailboxRoleChoices` (utilisateur → BAL)
|
||||
|
||||
| Valeur | Nom | Portée |
|
||||
|-------:|-----|--------|
|
||||
| 1 | `VIEWER` | Lecture des fils et messages |
|
||||
| 2 | `EDITOR` | Création/édition de drafts, labels, flags partagés, gestion partage |
|
||||
| 3 | `SENDER` | `EDITOR` + envoi des messages |
|
||||
| 4 | `ADMIN` | `SENDER` + gestion des accès, templates, import |
|
||||
|
||||
Groupes pré-calculés :
|
||||
```python
|
||||
VIEWER = 1 # Read-only: view mailbox threads/messages
|
||||
EDITOR = 2 # Edit: create drafts, flag, delete, manage thread access
|
||||
SENDER = 3 # Send: EDITOR + can send messages
|
||||
ADMIN = 4 # Admin: SENDER + manage mailbox accesses, labels, templates, import
|
||||
```
|
||||
|
||||
Role groups defined in `enums.py`:
|
||||
- `MAILBOX_ROLES_CAN_EDIT = [EDITOR, SENDER, ADMIN]`
|
||||
- `MAILBOX_ROLES_CAN_SEND = [SENDER, ADMIN]`
|
||||
|
||||
> ⚠️ Les comparaisons numériques (`role >= EDITOR`) sont utilisées dans `Mailbox.get_abilities()`. Elles reposent sur le fait que la hiérarchie est strictement inclusive — toute nouvelle valeur intermédiaire casserait cette invariante.
|
||||
|
||||
### 2.2 `ThreadAccessRoleChoices` (BAL → fil)
|
||||
|
||||
| Valeur | Nom |
|
||||
|-------:|-----|
|
||||
| 1 | `VIEWER` |
|
||||
| 2 | `EDITOR` |
|
||||
|
||||
### 2.3 `MailDomainAccessRoleChoices`
|
||||
|
||||
Un seul rôle : `ADMIN` (valeur 1).
|
||||
|
||||
### 2.4 Abilities exposées dans l'API
|
||||
|
||||
Les abilities sont des **clés plates** que l'API renvoie dans les serializers afin que le frontend masque ou grise les actions non autorisées.
|
||||
|
||||
- `UserAbilities` : `view_maildomains`, `create_maildomains`, `manage_maildomain_accesses`
|
||||
- `CRUDAbilities` : `get`, `post`, `put`, `patch`, `delete`
|
||||
- `MailDomainAbilities` : `manage_accesses`, `manage_mailboxes`
|
||||
- `MailboxAbilities` : `manage_accesses`, `view_messages`, `send_messages`, `manage_labels`, `manage_message_templates`, `import_messages`
|
||||
- `ThreadAbilities` : `edit`
|
||||
|
||||
> Les modèles `ThreadAccess`, `Message`, `Label`, `ThreadEvent`, `UserEvent` n'ont **pas** de `get_abilities()` et n'émettent donc pas de clé `abilities` dans leur payload. Voir §5 — c'est un des points de l'état des lieux.
|
||||
|
||||
---
|
||||
|
||||
## 3. Mécanismes techniques
|
||||
|
||||
### 3.1 Couches impliquées
|
||||
|
||||
| Couche | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Classes de permission DRF | `src/backend/core/api/permissions.py` | Gate d'accès (HTTP) avant l'exécution de la vue |
|
||||
| Méthodes `get_abilities()` | `src/backend/core/models.py` | Calcul des droits d'un utilisateur sur une instance |
|
||||
| `AbilitiesModelSerializer` | `src/backend/core/api/serializers.py` | Injection automatique du champ `abilities` dans la sortie JSON |
|
||||
| QuerySet `editable_by` | `ThreadAccessQuerySet` (`models.py`) | Source de vérité SQL pour « full edit rights » |
|
||||
| Annotations viewsets | `ThreadViewSet._annotate_thread_permissions` | Calcul en masse via subqueries (`_can_edit`, `_has_unread`, `_has_starred`, `_has_mention`…) |
|
||||
|
||||
### 3.2 Classes de permission DRF
|
||||
|
||||
| Classe | Portée | Utilisée par |
|
||||
|---|---|---|
|
||||
| `IsAuthenticated` | Vérifie `request.auth` ou `request.user.is_authenticated` | Quasiment toutes les vues |
|
||||
| `IsSuperUser` | `user.is_superuser` uniquement | `AdminMailDomainViewSet.create`, `UserViewSet.list` |
|
||||
| `IsSelf` | `obj == request.user` | `UserViewSet.get_me` |
|
||||
| `IsAllowedToAccess` | Accès à une BAL / un fil / un message selon contexte URL et action | `MessageViewSet`, `ThreadEventViewSet` (lecture), `ChangeFlagView`, `SendMessageView` |
|
||||
| `IsAllowedToCreateMessage` | Droits `EDITOR+` sur la BAL émettrice et accès au fil parent/draft | `DraftMessageView` |
|
||||
| `IsAllowedToManageThreadAccess` | Droits d'édition pleins sur le fil, URL `thread_id` obligatoire | `ThreadAccessViewSet` |
|
||||
| `HasThreadEditAccess` | Droits d'édition pleins sur le fil (via `editable_by`) | Actions `destroy`, `split`, `refresh_summary` de `ThreadViewSet` |
|
||||
| `HasThreadEventWriteAccess` | Écriture sur `ThreadEvent`, règle **dépend du type** (voir §4.1) | Actions d'écriture de `ThreadEventViewSet` |
|
||||
| `HasThreadCommentAccess` | Auteur possible d'un commentaire interne | `ThreadUserViewSet` (lister les mentionnables) |
|
||||
| `IsMailDomainAdmin` | `MailDomainAccess.role == ADMIN` sur le `maildomain_pk` URL | Administration de domaine (nested routers) |
|
||||
| `IsMailboxAdmin` | `ADMIN` sur la mailbox ou sur son domaine, ou superuser | `MailboxAccessViewSet` |
|
||||
| `HasAccessToMailbox` | Existence d'un `MailboxAccess` sur `mailbox_id` URL | `ChannelViewSet` (BAL) |
|
||||
| `HasChannelScope` + `channel_scope(...)` | Scope d'un token `Channel` (api_key) | Endpoints appelés par des intégrations (webhook, widget, mta) |
|
||||
| `IsGlobalChannelMixin` | Renforce que le `Channel` est `scope_level=global` | Endpoints globaux (metrics, création de domaine) |
|
||||
| `DenyAll` | Refuse systématiquement | Désactivation conditionnelle par feature flag |
|
||||
|
||||
### 3.3 `get_abilities()` et `AbilitiesModelSerializer`
|
||||
|
||||
Les serializers qui héritent de `AbilitiesModelSerializer` injectent automatiquement un champ `abilities` dont la valeur est le résultat de `instance.get_abilities(request.user)`.
|
||||
### ThreadAccessRoleChoices (Mailbox access to Thread)
|
||||
|
||||
```python
|
||||
class AbilitiesModelSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
...
|
||||
if not self.exclude_abilities:
|
||||
self.fields["abilities"] = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_abilities(self, instance):
|
||||
request = self.context.get("request")
|
||||
if not request:
|
||||
return {}
|
||||
if isinstance(instance, models.User):
|
||||
return instance.get_abilities()
|
||||
return instance.get_abilities(request.user)
|
||||
VIEWER = 1 # Read-only: view thread messages and events
|
||||
EDITOR = 2 # Edit: create replies, flag messages, manage thread sharing, assign
|
||||
```
|
||||
|
||||
Cela permet au frontend de lire les droits côté réponse API **sans refaire lui-même de logique** et de masquer les boutons non autorisés.
|
||||
Role group:
|
||||
- `THREAD_ROLES_CAN_EDIT = [EDITOR]`
|
||||
|
||||
### 3.4 QuerySet `ThreadAccess.objects.editable_by(user, mailbox_id=None)`
|
||||
|
||||
Source de vérité SQL pour « qui peut éditer ce fil ». Applique les deux conditions obligatoires **dans le même `.filter()`** (sinon Django génère deux jointures indépendantes et matche des paires d'accès incorrectes — noté dans le code) :
|
||||
### Event Types
|
||||
|
||||
```python
|
||||
qs = self.filter(
|
||||
role=ThreadAccessRoleChoices.EDITOR,
|
||||
mailbox__accesses__user=user,
|
||||
mailbox__accesses__role__in=MAILBOX_ROLES_CAN_EDIT,
|
||||
)
|
||||
# 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"
|
||||
```
|
||||
|
||||
Consommé par : `HasThreadEditAccess`, `HasThreadEventWriteAccess`, `ChangeFlagView`, `Thread.get_abilities`, `ThreadViewSet._annotate_thread_permissions` (via subquery).
|
||||
|
||||
---
|
||||
|
||||
## 4. Règles particulières
|
||||
|
||||
### 4.1 Événements de fil (`ThreadEvent`)
|
||||
|
||||
Trois types d'événements : `im` (message interne / commentaire), `assign`, `unassign`. La gate d'écriture dépend du type (`HasThreadEventWriteAccess`) :
|
||||
|
||||
| Type | Création | Update / Destroy |
|
||||
|---|---|---|
|
||||
| `im` | `MailboxAccess ∈ {EDITOR, SENDER, ADMIN}` + **n'importe quel** `ThreadAccess` | Mêmes droits + **doit être l'auteur** + **dans la fenêtre d'édition** (`settings.MAX_THREAD_EVENT_EDIT_DELAY`) |
|
||||
| `assign` / `unassign` | Droits d'édition pleins (`editable_by`) | Droits d'édition pleins + auteur + fenêtre d'édition |
|
||||
|
||||
La fenêtre d'édition (`is_editable()`) est vérifiée dans `ThreadEventViewSet.perform_update` / `perform_destroy`. Une valeur `0` désactive la limitation.
|
||||
|
||||
Les créations `assign` / `unassign` sont **idempotentes** : `ThreadEventViewSet.create` filtre les assignés déjà existants pour éviter de créer un événement vide.
|
||||
|
||||
### 4.2 Mentions et `UserEvent`
|
||||
|
||||
`UserEvent` n'a **pas** de ViewSet dédié ni de permission class propre. Il est créé par des signaux (`src/backend/core/signals.py`) à partir d'un `ThreadEvent` de type `im` contenant des mentions ou `assign`/`unassign`.
|
||||
|
||||
- **Lecture des mentions** : exposée comme annotation (`_has_unread_mention`, `_has_mention`) dans `ThreadViewSet` et comme champ de `ThreadEventSerializer`. Requête implicite : le propriétaire lit ses propres `UserEvent` via les filtres sur le user courant.
|
||||
- **Acquittement** : action custom `ThreadEventViewSet.read_mention` (PATCH `threads/{id}/events/{id}/read-mention/`). Requiert `IsAllowedToAccess` (lecture du fil). Met à jour uniquement les `UserEvent` du user courant → idempotent.
|
||||
|
||||
### 4.3 Flags de fil (`ChangeFlagView`)
|
||||
|
||||
Endpoint : `POST /api/v1.0/flag/`. Permission : `IsAllowedToAccess` (authentifié). La logique d'accès par flag est réalisée **dans la vue** :
|
||||
|
||||
| Flag | Queryset filtrant l'accès | Intention |
|
||||
|---|---|---|
|
||||
| `unread`, `starred` | `ThreadAccess.objects.filter(mailbox__accesses__user=user)` | Action personnelle → `VIEWER` suffit |
|
||||
| `trashed`, `archived`, `spam` | `ThreadAccess.objects.editable_by(user)` | État partagé → droits d'édition pleins |
|
||||
|
||||
### 4.4 Labels
|
||||
|
||||
Un label appartient à une **seule BAL**. Le droit `manage_labels` est porté par `Mailbox.get_abilities()` (valeur = `can_modify`, donc `EDITOR+`).
|
||||
|
||||
Attacher / détacher un label à un fil (`/labels/{id}/add-threads/`) requiert :
|
||||
- `MailboxAccess ∈ {EDITOR, SENDER, ADMIN}` sur la BAL du label ;
|
||||
- que le fil cible appartienne à la BAL du label (pas besoin d'être `EDITOR` sur le fil lui-même — commentaire explicite dans `label.py:294-301`).
|
||||
|
||||
Cette règle a été un choix conscient : le label est un outil d'organisation **local** à la BAL.
|
||||
|
||||
### 4.5 Brouillons et envoi
|
||||
|
||||
- `DraftMessageView` : `IsAllowedToCreateMessage` vérifie `senderId` + `MailboxAccess ∈ MAILBOX_ROLES_CAN_EDIT`. Pour les réponses, exige un `ThreadAccess.EDITOR` entre la mailbox et le fil parent. Pour l'update, exige idem sur la mailbox et le fil du draft.
|
||||
- `SendMessageView` : `IsAllowedToAccess` + vérification explicite dans la vue que la mailbox émettrice est bien un `ThreadAccess` du fil (jointure au fetch). Ne vérifie pas explicitement `MAILBOX_ROLES_CAN_SEND` — la gate réelle est sur `MailboxAccess` des mailboxes concernées via `IsAllowedToAccess.has_object_permission` (branche `view.action == "send"` → `MAILBOX_ROLES_CAN_SEND`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Matrice des droits
|
||||
|
||||
Légende des colonnes :
|
||||
- `U-SU` : superuser ;
|
||||
- `U-MD-ADMIN` : admin du `MailDomain` ;
|
||||
- `M-A/S/E/V` : `MailboxAccess` ADMIN / SENDER / EDITOR / VIEWER ;
|
||||
- `T-E/V` : `ThreadAccess` EDITOR / VIEWER ;
|
||||
- Dans la matrice : ✅ autorisé, ⚠️ autorisé sous condition additionnelle, ❌ refusé, — non applicable.
|
||||
|
||||
### 5.1 `MailDomain`
|
||||
|
||||
Défini dans `MailDomain.get_abilities(user)` (`models.py:364`) et les permissions `AdminMailDomainViewSet`.
|
||||
|
||||
| Action | Endpoint | Superuser | `U-MD-ADMIN` | Autres |
|
||||
|---|---|:-:|:-:|:-:|
|
||||
| Lister les domaines dont l'user a un accès | `GET /maildomains/` | ✅ | ✅ | ❌ |
|
||||
| Voir un domaine | `GET /maildomains/{id}/` | ✅ | ✅ | ❌ |
|
||||
| Créer un domaine | `POST /maildomains/` | ⚠️ feature flag `FEATURE_MAILDOMAIN_CREATE` | ❌ | ❌ |
|
||||
| Mettre à jour / supprimer | ❌ non exposé via API | — | — | — |
|
||||
| Gérer les accès du domaine | Ability `manage_accesses` | ✅ | ✅ | ❌ |
|
||||
| Gérer les BALs du domaine | Ability `manage_mailboxes` | ✅ | ✅ | ❌ |
|
||||
| Vérification DNS | `GET /maildomains/{id}/check-dns/` | ✅ | ✅ | ❌ |
|
||||
|
||||
### 5.2 `MailDomainAccess`
|
||||
|
||||
`MaildomainAccessViewSet` — permissions `IsSuperUser | IsMailDomainAdmin`.
|
||||
|
||||
| Action | Superuser | `U-MD-ADMIN` | Autres | Notes |
|
||||
|---|:-:|:-:|:-:|---|
|
||||
| Lister | ✅ | ✅ | ❌ | |
|
||||
| Lire | ✅ | ✅ | ❌ | |
|
||||
| Créer | ⚠️ | ⚠️ | ❌ | Gated par `FEATURE_MAILDOMAIN_MANAGE_ACCESSES` |
|
||||
| Supprimer | ⚠️ | ⚠️ | ❌ | Idem |
|
||||
| Modifier | — | — | — | Pas de `UpdateModelMixin` (rôle unique `ADMIN`) |
|
||||
|
||||
### 5.3 `Mailbox`
|
||||
|
||||
Défini dans `Mailbox.get_abilities(user)` (`models.py:778`). Accès via `MailboxViewSet` + feature flags.
|
||||
|
||||
| Ability | `M-A` | `M-S` | `M-E` | `M-V` | Aucun |
|
||||
|---|:-:|:-:|:-:|:-:|:-:|
|
||||
| `get` (lire la BAL) | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| `post` / `patch` / `put` (créer/modifier) | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| `delete` | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `manage_accesses` | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
| `view_messages` | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| `send_messages` | ✅ | ✅ | ❌ | ❌ | ❌ |
|
||||
| `manage_labels` | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| `manage_message_templates` | ⚠️ flag | ❌ | ❌ | ❌ | ❌ |
|
||||
| `import_messages` | ⚠️ flag | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
> Les flags `FEATURE_MESSAGE_TEMPLATES` et `FEATURE_IMPORT_MESSAGES` basculent les abilities à `False` quel que soit le rôle.
|
||||
|
||||
### 5.4 `MailboxAccess`
|
||||
|
||||
`MailboxAccessViewSet` sous `/mailboxes/{id}/accesses/`. Permission : `IsMailboxAdmin` (ADMIN de la BAL ou de son domaine, ou superuser).
|
||||
|
||||
| Action | `M-A` sur cible | `U-MD-ADMIN` du domaine | Superuser | Autres |
|
||||
|---|:-:|:-:|:-:|:-:|
|
||||
| Lister / Lire | ✅ | ✅ | ✅ | ❌ |
|
||||
| Créer | ✅ | ✅ | ✅ | ❌ |
|
||||
| Modifier le rôle | ✅ | ✅ | ✅ | ❌ |
|
||||
| Supprimer | ✅ | ✅ | ✅ | ❌ |
|
||||
|
||||
### 5.5 `Thread`
|
||||
|
||||
`ThreadViewSet`. Permission par défaut : `IsAuthenticated`. Les abilities (`ThreadAbilities.CAN_EDIT`) sont exposées via `ThreadSerializer.get_abilities`.
|
||||
|
||||
| Action | Endpoint | `T-E` + `M-CAN_EDIT` | `T-V` ou `M-V` | Aucun accès |
|
||||
|---|---|:-:|:-:|:-:|
|
||||
| Lister les fils accessibles | `GET /threads/` | ✅ | ✅ | ❌ |
|
||||
| Lire un fil | `GET /threads/{id}/` | ✅ | ✅ | ❌ |
|
||||
| Supprimer un fil | `DELETE /threads/{id}/` | ✅ | ❌ | ❌ |
|
||||
| Scinder un fil | `POST /threads/{id}/split/` | ✅ | ❌ | ❌ |
|
||||
| Rafraîchir le résumé IA | `POST /threads/{id}/refresh-summary/` | ✅ | ❌ | ❌ |
|
||||
| Stats agrégées | `GET /threads/stats/` | ✅ | ✅ | ❌ |
|
||||
|
||||
> L'appartenance au fil est calculée via `ThreadAccess` : une BAL dont l'user a un `MailboxAccess` quelconque suffit pour la lecture.
|
||||
|
||||
### 5.6 `ThreadAccess`
|
||||
|
||||
`ThreadAccessViewSet` sous `/threads/{thread_id}/accesses/`. Permission : `IsAllowedToManageThreadAccess`.
|
||||
|
||||
| Action | `T-E` + `M-CAN_EDIT` | `T-V` + `M-CAN_EDIT` | `M-V` | Aucun |
|
||||
|---|:-:|:-:|:-:|:-:|
|
||||
| Lister les accesses du fil | ✅ | ❌ | ❌ | ❌ |
|
||||
| Créer un access (partager) | ✅ | ❌ | ❌ | ❌ |
|
||||
| Mettre à jour (changer le rôle) | ✅ | ❌ | ❌ | ❌ |
|
||||
| Supprimer son propre access | ⚠️ voir note | ⚠️ voir note | ❌ | ❌ |
|
||||
|
||||
> **Note supprimer** : `destroy` est permis si l'utilisateur est `M-CAN_EDIT` sur la BAL concernée (*y compris sans EDITOR sur le fil*), pour autoriser une BAL à se retirer du fil. Une garde empêche la suppression du **dernier** EDITOR d'un fil (`perform_destroy` avec verrou `SELECT FOR UPDATE`).
|
||||
|
||||
### 5.7 `Message`
|
||||
|
||||
`MessageViewSet` + `ChangeFlagView` + `SendMessageView` + `DraftMessageView`.
|
||||
|
||||
| Action | Endpoint | `T-E` + `M-CAN_EDIT` | `T-V` ou `M-V` | `M-SEND` sur la BAL émettrice |
|
||||
|---|---|:-:|:-:|:-:|
|
||||
| Lister / Lire | `GET /messages/` | ✅ | ✅ | — |
|
||||
| Télécharger EML | `GET /messages/{id}/eml/` | ✅ | ✅ | — |
|
||||
| Supprimer | `DELETE /messages/{id}/` | ✅ | ❌ | — |
|
||||
| Changer flags (unread, starred) | `POST /flag/` | ✅ | ✅ | — |
|
||||
| Changer flags (trashed, archived, spam) | `POST /flag/` | ✅ | ❌ | — |
|
||||
| Mettre à jour un statut de livraison | `PATCH /messages/{id}/delivery-statuses/` | ✅ | ❌ | ✅ requis |
|
||||
| Créer / MAJ un brouillon | `POST /draft/` | ✅ (`M-CAN_EDIT` sur émettrice) | ❌ | ✅ |
|
||||
| Envoyer | `POST /send/` | ✅ + `M-CAN_SEND` sur émettrice | ❌ | ✅ |
|
||||
|
||||
### 5.8 `Label`
|
||||
|
||||
`LabelViewSet`. Permission : `IsAuthenticated` + vérifications manuelles dans la vue.
|
||||
|
||||
| Action | `M-CAN_EDIT` sur BAL du label | `M-V` sur BAL | Aucun accès BAL |
|
||||
|---|:-:|:-:|:-:|
|
||||
| Lister | ✅ | ✅ (read seul) | ❌ |
|
||||
| Créer | ✅ | ❌ | ❌ |
|
||||
| Modifier | ✅ | ❌ | ❌ |
|
||||
| Supprimer | ✅ | ❌ | ❌ |
|
||||
| `add-threads` / `remove-threads` | ✅ | ❌ | ❌ |
|
||||
|
||||
### 5.9 `ThreadEvent`
|
||||
|
||||
`ThreadEventViewSet` sous `/threads/{thread_id}/events/`. Lecture : `IsAllowedToAccess`. Écriture : `HasThreadEventWriteAccess`.
|
||||
|
||||
| Action | Type | `T-E` + `M-CAN_EDIT` | `T-V` + `M-CAN_EDIT` | `T-E` + `M-V` | `T-V` + `M-V` ou aucun |
|
||||
|---|---|:-:|:-:|:-:|:-:|
|
||||
| Lister / Lire | tous | ✅ | ✅ | ✅ | ❌ |
|
||||
| `read-mention` (acquittement personnel) | — | ✅ | ✅ | ✅ | ❌ |
|
||||
| Créer | `im` | ✅ | ✅ | ❌ | ❌ |
|
||||
| Créer | `assign` / `unassign` | ✅ | ❌ | ❌ | ❌ |
|
||||
| Update / Destroy (auteur uniquement, fenêtre valide) | `im` | ✅ | ✅ | ❌ | ❌ |
|
||||
| Update / Destroy (auteur uniquement, fenêtre valide) | `assign` / `unassign` | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
> La vérification « auteur uniquement » est faite dans `HasThreadEventWriteAccess.has_object_permission` et `HasThreadEditAccess.has_object_permission`. La fenêtre temporelle est `settings.MAX_THREAD_EVENT_EDIT_DELAY` et est vérifiée dans `perform_update` / `perform_destroy`.
|
||||
|
||||
### 5.10 `UserEvent`
|
||||
|
||||
Pas de viewset dédié. Accès uniquement via :
|
||||
|
||||
| Action | Mécanisme | Droits |
|
||||
|---|---|---|
|
||||
| Lister mes mentions / assignations | Annotations `_has_mention`, `_has_unread_mention`, `_has_assigned_to_me`, `_has_unassigned` dans `ThreadViewSet` | Accès au fil |
|
||||
| Acquitter une mention | Action `read-mention` de `ThreadEventViewSet` | Accès au fil |
|
||||
| Créer | Signal `post_save` sur `ThreadEvent` (cœur, pas d'API) | — |
|
||||
|
||||
Conséquence : un `UserEvent` est **intrinsèquement lié à son destinataire** — il n'y a aucun endpoint permettant à un autre utilisateur d'y accéder directement.
|
||||
|
||||
### 5.11 `User`
|
||||
|
||||
`UserViewSet`.
|
||||
|
||||
| Action | Endpoint | Superuser | `U-MD-ADMIN` | Lui-même | Autres |
|
||||
|---|---|:-:|:-:|:-:|:-:|
|
||||
| `me` | `GET /users/me/` | ✅ | ✅ | ✅ | ✅ |
|
||||
| Rechercher un user (≥ 3 caractères) | `GET /users/?q=...` | ✅ (global) | ✅ (scope maildomain) | — | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 6. État des lieux
|
||||
|
||||
### 6.1 Points forts
|
||||
|
||||
- **Séparation stricte à deux niveaux** (BAL ↔ Fil) : permet un partage fil-par-fil sans dupliquer la gestion des utilisateurs.
|
||||
- **Source de vérité SQL unique** pour « droits d'édition pleins » : `ThreadAccess.objects.editable_by()`. Utilisée à la fois en permission class et en annotation viewset, ce qui évite les divergences.
|
||||
- **Pattern `AbilitiesModelSerializer`** élégant : le frontend n'a qu'à lire le champ `abilities` pour conditionner l'UI.
|
||||
- **Commentaires IM relaxés** : règle produit fine (un `VIEWER` d'un fil mais `EDITOR` de sa BAL peut poster un commentaire interne) bien encapsulée dans `HasThreadEventWriteAccess`.
|
||||
- **Idempotence** sur `assign`/`unassign` et `read-mention` : gestion des doublons côté serveur, client simplifié.
|
||||
- **Garde-fou « dernier EDITOR »** sur `ThreadAccess.destroy` avec verrou `SELECT FOR UPDATE` pour gérer la concurrence.
|
||||
- **Feature flags DRF** (`DenyAll`) : les endpoints désactivés répondent 403 proprement plutôt qu'une 404 ambiguë.
|
||||
|
||||
### 6.2 Dette technique identifiée
|
||||
|
||||
1. **Couverture incomplète des abilities**. Les modèles `ThreadAccess`, `Message`, `Label`, `ThreadEvent`, `UserEvent` **n'ont pas** de méthode `get_abilities()`. Conséquence : le frontend doit **ré-inférer** les droits à partir des abilities du `Thread` ou de la `Mailbox`. C'est fragile dès qu'une règle évolue côté backend (ex. fenêtre d'édition de `ThreadEvent`, règle `can_modify` des labels, règle d'envoi). **Amélioration suggérée** : ajouter `get_abilities()` au moins sur `Label`, `ThreadEvent` (en exposant `can_edit`, `can_delete`, `is_editable`) et `Message` (`can_delete`, `can_send`, `can_change_flag`).
|
||||
|
||||
2. **Logique de permission dispersée entre trois couches**. Pour `Label`, par exemple, la logique vit simultanément dans :
|
||||
- `LabelViewSet.check_mailbox_permissions` (vérif explicite)
|
||||
- `LabelSerializer.validate_mailbox` (vérif à la création)
|
||||
- `LabelViewSet.get_object` (vérif de lecture)
|
||||
- `Mailbox.get_abilities()` (ability `manage_labels`)
|
||||
Cela rend les évolutions risquées. **Amélioration** : centraliser dans une permission class dédiée `IsAllowedToManageLabel`.
|
||||
|
||||
3. **Couplage fort de `IsAllowedToAccess`**. Cette classe reçoit plusieurs types d'objets (`Mailbox`, `Thread`, `Message`, `ThreadEvent`) et **hard-code les noms d'actions** (`destroy`, `send`, `delivery_statuses`). Elle mêle gate et logique métier. Symptôme typique de *god-permission*. **Amélioration** : éclater en plusieurs classes dédiées (`IsAllowedToAccessMessage`, `IsAllowedToAccessThread`, …).
|
||||
|
||||
4. **Duplication de la logique `editable_by` dans les permissions**. `IsAllowedToManageThreadAccess.has_permission` répète le même filtre inline que `ThreadAccess.objects.editable_by()` alors qu'elle pourrait simplement l'appeler. Idem dans `IsAllowedToAccess.has_object_permission` (branche `destroy`/`send`/`delivery_statuses`). Risque de divergence si `editable_by` évolue.
|
||||
|
||||
5. **Dictionnaire `ACTION_FOR_METHOD_TO_PERMISSION`** en tête de `permissions.py` référence des actions (`versions_detail`, `children`) qui **ne correspondent à aucune vue actuelle**. Vraisemblable reste d'une version antérieure du modèle de données. Candidat à la suppression.
|
||||
|
||||
6. **Feature flags éparpillés**. `FEATURE_MESSAGE_TEMPLATES` et `FEATURE_IMPORT_MESSAGES` sont appliqués dans `Mailbox.get_abilities()` ; `FEATURE_MAILDOMAIN_CREATE` et `FEATURE_MAILDOMAIN_MANAGE_ACCESSES` le sont dans les `get_permissions()` via `DenyAll`. Incohérence qui rend la matrice de droits plus difficile à lire. **Amélioration** : convention unique (par exemple : toujours flag côté `get_permissions`).
|
||||
|
||||
7. **`MailboxRoleChoices` reposant sur la comparaison numérique**. `Mailbox.get_abilities()` utilise `role >= EDITOR`. Si l'on ajoute un rôle intermédiaire entre `EDITOR` (2) et `SENDER` (3), la sémantique dérive silencieusement. **Amélioration** : préférer l'appartenance à un groupe explicite (`role in MAILBOX_ROLES_CAN_EDIT`), ce qui est d'ailleurs le style utilisé partout ailleurs.
|
||||
|
||||
8. **`SendMessageView` ne vérifie pas `MAILBOX_ROLES_CAN_SEND` de manière directe**. La gate repose sur `IsAllowedToAccess.has_object_permission` branche `send`. L'endpoint serait plus lisible avec une permission dédiée `CanSendFromMailbox`.
|
||||
|
||||
9. **Annotation `events_count`** dans `ThreadViewSet` compte **tous** les événements du fil, y compris les `assign`/`unassign` et les `im`. Pas un bug au sens strict mais la sémantique de ce compteur est ambiguë pour le frontend (un badge « 3 événements » mélangera commentaires et mouvements d'assignation).
|
||||
|
||||
10. **Absence de permission class pour `UserEvent`**. Tant que les seuls accès passent par des annotations limitées à `request.user`, le modèle de permission est sûr ; mais toute nouvelle exposition (ex. liste des mentions d'un user admin sur son domaine) devra réinventer la gate.
|
||||
|
||||
### 6.3 Risques fonctionnels / bugs suspects
|
||||
|
||||
- **`IsAllowedToAccess.has_permission` — branche `is_list_action=False` sur route imbriquée** : hors création, on retourne `True` sans vérifier l'appartenance, en déléguant à `has_object_permission`. C'est correct pour les actions `retrieve` / `update` / `destroy` qui transitent par `get_object()`. C'est en revanche **risqué** si une action custom est ajoutée et oublie d'appeler `self.check_object_permissions(...)`. **Mitigation conseillée** : audit systématique des `@action` custom de `ThreadEventViewSet` / `MessageViewSet`.
|
||||
|
||||
- **`IsMailboxAdmin.has_object_permission` : `obj.mailbox.domain` doit être non nul**. Si une `MailboxAccess` référence une mailbox sans domaine (cas dégénéré mais possible côté migration), la méthode retourne `False` → l'admin légitime est bloqué. À valider par une contrainte DB.
|
||||
|
||||
- **`Thread.get_abilities(user, mailbox_id=...)` retourne `{CAN_EDIT: can_edit}` sans clé `CAN_READ` ni CRUD**. Le frontend ne peut pas distinguer « je ne peux pas éditer » de « je ne peux pas lire » via cet appel seul. Acceptable tant que l'appel ne survient qu'après un accès confirmé, mais source d'ambiguïté dans le JSON de réponse.
|
||||
|
||||
- **`ChangeFlagView`** calcule l'ensemble `accessible_thread_ids_qs` sans le `.distinct()` habituel. Pas d'incidence sur la correction du filtre (`id__in` déduplique), mais peut générer des plans d'exécution plus coûteux qu'il n'en faut.
|
||||
|
||||
- **`ThreadUserViewSet`** expose la liste des utilisateurs accédant à un fil. Permission : `HasThreadCommentAccess` (= `M-CAN_EDIT` + `ThreadAccess` quelconque). Un `VIEWER` ThreadAccess qui est `EDITOR` sur sa BAL peut ainsi **énumérer** tous les autres membres. Ce n'est pas un bug (nécessaire pour le flow mention), mais à garder en tête côté confidentialité.
|
||||
|
||||
### 6.4 Pistes d'amélioration priorisées
|
||||
|
||||
| Priorité | Action | Gain |
|
||||
|:-:|---|---|
|
||||
| P1 | Ajouter `get_abilities()` sur `Label`, `ThreadEvent`, `Message` | Cohérence front/back, moins de logique dupliquée côté UI |
|
||||
| P1 | Centraliser les vérifs `Label` dans une permission class | Lisibilité + robustesse |
|
||||
| P2 | Éclater `IsAllowedToAccess` par type d'objet | Baisse de complexité cyclomatique, testabilité |
|
||||
| P2 | Remplacer la logique inline `editable_by` dans `IsAllowedToManageThreadAccess` par l'appel au queryset manager | Source de vérité unique |
|
||||
| P3 | Supprimer `ACTION_FOR_METHOD_TO_PERMISSION` | Nettoyage de code mort |
|
||||
| P3 | Uniformiser le placement des feature flags (toujours via `get_permissions` + `DenyAll`) | Lisibilité de la matrice |
|
||||
| P3 | Documenter (tests snapshot) la matrice de droits pour détecter les régressions silencieuses | Filet de sécurité |
|
||||
|
||||
---
|
||||
|
||||
## 7. Références
|
||||
|
||||
- `src/backend/core/api/permissions.py` — classes de permission DRF
|
||||
- `src/backend/core/models.py` — modèles, `get_abilities()`, `ThreadAccessQuerySet.editable_by`
|
||||
- `src/backend/core/api/serializers.py` — `AbilitiesModelSerializer`, injection du champ `abilities`
|
||||
- `src/backend/core/api/viewsets/` — gates par viewset
|
||||
- `src/backend/core/enums.py` — rôles, groupes de rôles, abilities
|
||||
- `src/backend/core/urls.py` — routing des viewsets
|
||||
- `src/backend/core/signals.py` — création automatique de `UserEvent` à partir d'un `ThreadEvent`
|
||||
`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 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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 330 KiB |
@@ -5252,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",
|
||||
@@ -5284,7 +5275,10 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaginatedThreadAccessList"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ThreadAccess"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5579,7 +5573,7 @@
|
||||
},
|
||||
"post": {
|
||||
"operationId": "threads_events_create",
|
||||
"description": "Create a ThreadEvent with idempotence for ASSIGN and UNASSIGN.\n\nFor ASSIGN (per D-08): if all assignees already have a UserEvent ASSIGN\non this thread, return 200 without creating a ThreadEvent. If some are\nnew, filter data.assignees to new ones only and create.\n\nFor UNASSIGN: if no assignee has a UserEvent ASSIGN on this thread,\nreturn 200 without creating a ThreadEvent.",
|
||||
"description": "Create a ThreadEvent\n\nFor ASSIGN: if all assignees already have a UserEvent ASSIGN\non this thread, return 204 without creating a ThreadEvent. If some\nare new, filter data.assignees to new ones only and create.\n\nFor UNASSIGN: if no assignee has a UserEvent ASSIGN on this thread,\nreturn 204 without creating a ThreadEvent.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
@@ -5905,7 +5899,7 @@
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserWithoutAbilities"
|
||||
"$ref": "#/components/schemas/ThreadMentionableUser"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8401,37 +8395,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": [
|
||||
@@ -9069,6 +9032,13 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"readOnly": true
|
||||
},
|
||||
"assigned_users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ThreadEventUser"
|
||||
},
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9076,6 +9046,7 @@
|
||||
"accesses",
|
||||
"active_messaged_at",
|
||||
"archived_messaged_at",
|
||||
"assigned_users",
|
||||
"draft_messaged_at",
|
||||
"events_count",
|
||||
"has_active",
|
||||
@@ -9142,6 +9113,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": [
|
||||
@@ -9150,7 +9128,8 @@
|
||||
"mailbox",
|
||||
"role",
|
||||
"thread",
|
||||
"updated_at"
|
||||
"updated_at",
|
||||
"users"
|
||||
]
|
||||
},
|
||||
"ThreadAccessDetail": {
|
||||
@@ -9185,13 +9164,6 @@
|
||||
"format": "date-time",
|
||||
"readOnly": true,
|
||||
"nullable": true
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserWithoutAbilities"
|
||||
},
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -9199,8 +9171,7 @@
|
||||
"mailbox",
|
||||
"read_at",
|
||||
"role",
|
||||
"starred_at",
|
||||
"users"
|
||||
"starred_at"
|
||||
]
|
||||
},
|
||||
"ThreadAccessRequest": {
|
||||
@@ -9315,7 +9286,7 @@
|
||||
},
|
||||
"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 ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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",
|
||||
@@ -9330,7 +9301,7 @@
|
||||
},
|
||||
"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 ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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",
|
||||
@@ -9365,7 +9336,7 @@
|
||||
},
|
||||
"ThreadEventIMData": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.\n\nNot used for runtime validation (handled by ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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"
|
||||
@@ -9383,7 +9354,7 @@
|
||||
},
|
||||
"ThreadEventIMDataRequest": {
|
||||
"type": "object",
|
||||
"description": "OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.\n\nNot used for runtime validation (handled by ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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",
|
||||
@@ -9433,7 +9404,7 @@
|
||||
},
|
||||
"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 ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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",
|
||||
@@ -9450,7 +9421,7 @@
|
||||
},
|
||||
"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 ``ThreadEvent.clean()`` against\n``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component\nin the OpenAPI schema consumed by the generated frontend client.",
|
||||
"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",
|
||||
@@ -9514,6 +9485,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": {
|
||||
|
||||
@@ -45,9 +45,10 @@ 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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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()
|
||||
@@ -65,9 +66,10 @@ class ThreadEventUserSerializer(serializers.Serializer):
|
||||
class ThreadEventIMDataSerializer(serializers.Serializer):
|
||||
"""OpenAPI-only serializer: shape of ``ThreadEvent.data`` for ``IM`` events.
|
||||
|
||||
Not used for runtime validation (handled by ``ThreadEvent.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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()
|
||||
@@ -88,9 +90,10 @@ class ThreadEventAssigneesDataSerializer(serializers.Serializer):
|
||||
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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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.
|
||||
"""
|
||||
|
||||
assignees = ThreadEventUserSerializer(many=True, min_length=1)
|
||||
@@ -117,11 +120,14 @@ class ThreadEventAssigneesDataSerializer(serializers.Serializer):
|
||||
class ThreadEventDataField(serializers.JSONField):
|
||||
"""JSONField for ``ThreadEvent.data``.
|
||||
|
||||
Runtime validation is performed by ``ThreadEvent.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``. 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
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -299,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."""
|
||||
|
||||
@@ -703,30 +719,12 @@ class ThreadAccessDetailSerializer(serializers.ModelSerializer):
|
||||
role = IntegerChoicesField(
|
||||
choices_class=models.ThreadAccessRoleChoices, read_only=True
|
||||
)
|
||||
users = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.ThreadAccess
|
||||
fields = ["id", "mailbox", "role", "read_at", "starred_at", "users"]
|
||||
fields = ["id", "mailbox", "role", "read_at", "starred_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
@extend_schema_field(UserWithoutAbilitiesSerializer(many=True))
|
||||
def get_users(self, instance):
|
||||
"""Return assignable users of the mailbox (viewers excluded).
|
||||
|
||||
Surfaced so the share modal can offer per-mailbox assignment without
|
||||
an extra round-trip per access.
|
||||
"""
|
||||
accesses = [
|
||||
access
|
||||
for access in instance.mailbox.accesses.all()
|
||||
if access.role != models.MailboxRoleChoices.VIEWER
|
||||
]
|
||||
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 ThreadSerializer(serializers.ModelSerializer):
|
||||
"""Serialize threads."""
|
||||
@@ -742,6 +740,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):
|
||||
@@ -795,29 +794,52 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
|
||||
@extend_schema_field(ThreadAccessDetailSerializer(many=True))
|
||||
def get_accesses(self, instance):
|
||||
"""Return the accesses for the thread."""
|
||||
# Prefetch mailbox.accesses + user so ThreadAccessDetailSerializer.get_users
|
||||
# can enumerate assignable users without N+1 queries.
|
||||
accesses = instance.accesses.select_related(
|
||||
"mailbox", "mailbox__contact"
|
||||
).prefetch_related("mailbox__accesses__user")
|
||||
"""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
|
||||
@@ -826,13 +848,46 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
|
||||
@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
|
||||
@@ -869,6 +924,7 @@ class ThreadSerializer(serializers.ModelSerializer):
|
||||
"summary",
|
||||
"events_count",
|
||||
"abilities",
|
||||
"assigned_users",
|
||||
]
|
||||
read_only_fields = fields # Mark all as read-only for safety
|
||||
|
||||
@@ -1111,23 +1167,43 @@ 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 (viewers excluded).
|
||||
|
||||
Scoped to the thread access because the UI renders the assignable
|
||||
users per-access. Viewers are excluded: they cannot be assigned.
|
||||
"""
|
||||
accesses = [
|
||||
access
|
||||
for access in instance.mailbox.accesses.all()
|
||||
if access.role != models.MailboxRoleChoices.VIEWER
|
||||
]
|
||||
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."""
|
||||
|
||||
_DATA_SERIALIZERS = {
|
||||
enums.ThreadEventTypeChoices.IM: ThreadEventIMDataSerializer,
|
||||
enums.ThreadEventTypeChoices.ASSIGN: ThreadEventAssigneesDataSerializer,
|
||||
enums.ThreadEventTypeChoices.UNASSIGN: ThreadEventAssigneesDataSerializer,
|
||||
}
|
||||
|
||||
author = UserWithoutAbilitiesSerializer(read_only=True)
|
||||
data = ThreadEventDataField()
|
||||
has_unread_mention = serializers.SerializerMethodField()
|
||||
@@ -1161,12 +1237,12 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
create_only_fields = ["type", "message", "author"]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate `data` against the sub-serializer matching `type`.
|
||||
"""Validate ``data`` against the JSON Schema registered for ``type``.
|
||||
|
||||
ThreadEvent.clean() enforces the same JSON Schema at save time, but
|
||||
routing through the dedicated sub-serializer here surfaces
|
||||
field-level 400 errors to the client instead of a generic
|
||||
ValidationError bubbled up from full_clean().
|
||||
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.
|
||||
@@ -1174,13 +1250,10 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
event_type = attrs.get("type") or getattr(self.instance, "type", None)
|
||||
data_serializer_cls = self._DATA_SERIALIZERS.get(event_type)
|
||||
if data_serializer_cls is None:
|
||||
return attrs
|
||||
|
||||
data_serializer = data_serializer_cls(data=attrs["data"])
|
||||
if not data_serializer.is_valid():
|
||||
raise serializers.ValidationError({"data": data_serializer.errors})
|
||||
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())
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
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
|
||||
@@ -144,11 +144,12 @@ class ThreadViewSet(
|
||||
|
||||
@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"),
|
||||
@@ -192,6 +193,54 @@ class ThreadViewSet(
|
||||
),
|
||||
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
|
||||
|
||||
@@ -44,6 +44,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 +56,12 @@ class ThreadAccessViewSet(
|
||||
# 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:
|
||||
|
||||
@@ -158,21 +158,12 @@ class ThreadEventViewSet(
|
||||
enums.ThreadEventTypeChoices.ASSIGN,
|
||||
enums.ThreadEventTypeChoices.UNASSIGN,
|
||||
):
|
||||
assignees_data = serializer.validated_data.get("data", {}).get(
|
||||
"assignees", []
|
||||
)
|
||||
assignee_ids = []
|
||||
for assignee in assignees_data:
|
||||
try:
|
||||
assignee_ids.append(uuid.UUID(assignee["id"]))
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if not assignee_ids:
|
||||
return Response(
|
||||
{"error": f"No valid assignees provided for {event_type} event"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# ``ThreadEventSerializer`` has already enforced the JSON Schema
|
||||
# (presence, non-empty list, UUID format) via
|
||||
# ``ThreadEvent.validate_data``, so no defensive parsing is needed
|
||||
# here.
|
||||
assignees_data = serializer.validated_data["data"]["assignees"]
|
||||
assignee_ids = [uuid.UUID(a["id"]) for a in assignees_data]
|
||||
|
||||
if event_type == enums.ThreadEventTypeChoices.ASSIGN:
|
||||
already_assigned = set(
|
||||
@@ -208,10 +199,29 @@ class ThreadEventViewSet(
|
||||
"Assignee must have editor access on the thread"
|
||||
)
|
||||
|
||||
# Update data to only include new assignees
|
||||
serializer.validated_data["data"]["assignees"] = new_assignees
|
||||
|
||||
elif event_type == enums.ThreadEventTypeChoices.UNASSIGN:
|
||||
# Narrow the payload to users currently assigned before doing
|
||||
# anything else.
|
||||
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 Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
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]
|
||||
serializer.validated_data["data"]["assignees"] = assignees_data
|
||||
|
||||
absorbed = self._absorb_unassign_in_undo_window(
|
||||
thread=thread,
|
||||
author=request.user,
|
||||
@@ -228,19 +238,6 @@ class ThreadEventViewSet(
|
||||
# Some assignees fell outside the undo window: narrow the
|
||||
# payload and let the regular UNASSIGN path handle them.
|
||||
serializer.validated_data["data"]["assignees"] = remaining_data
|
||||
assignees_data = remaining_data
|
||||
assignee_ids = [uuid.UUID(a["id"]) for a in remaining_data]
|
||||
|
||||
# Try to retrieve ASSIGN UserEvents for the targeted assignees
|
||||
has_active_assignment = models.UserEvent.objects.filter(
|
||||
thread=thread,
|
||||
user_id__in=assignee_ids,
|
||||
type=enums.UserEventTypeChoices.ASSIGN,
|
||||
).exists()
|
||||
|
||||
if not has_active_assignment:
|
||||
# No one to unassign - Nothing to do
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
serializer.save(thread=thread, author=request.user)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
@@ -309,9 +306,7 @@ class ThreadEventViewSet(
|
||||
|
||||
if absorbed:
|
||||
absorbed_data = [
|
||||
a
|
||||
for a in assignees_data
|
||||
if a.get("id") and uuid.UUID(a["id"]) in absorbed
|
||||
a for a in assignees_data if uuid.UUID(a["id"]) in absorbed
|
||||
]
|
||||
delete_assign_user_events(None, thread, absorbed_data)
|
||||
|
||||
|
||||
@@ -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 @@ class ThreadUserViewSet(
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ from django.utils import timezone
|
||||
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.enums import (
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -220,15 +220,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
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):
|
||||
@@ -311,15 +307,11 @@ class MailDomain(BaseModel):
|
||||
|
||||
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]:
|
||||
@@ -1449,14 +1441,9 @@ class Label(BaseModel):
|
||||
class ThreadAccessQuerySet(models.QuerySet):
|
||||
"""Custom queryset exposing reusable access-scoped filters."""
|
||||
|
||||
# Shared ORM conditions that define "full edit rights on a thread":
|
||||
# - ``ThreadAccess.role == EDITOR``
|
||||
# - ``MailboxAccess.role`` is in ``MAILBOX_ROLES_CAN_EDIT``
|
||||
# Single source of truth consumed by ``editable_by`` (per-user check) and
|
||||
# ``editor_user_ids`` (per-thread listing). Keep the two
|
||||
# ``mailbox__accesses__*`` conditions on the same ``.filter()`` call at the
|
||||
# call site — splitting them generates independent JOINs and matches any
|
||||
# pair of mailbox accesses satisfying either condition (false positive).
|
||||
# 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,
|
||||
@@ -1478,23 +1465,26 @@ class ThreadAccessQuerySet(models.QuerySet):
|
||||
return qs
|
||||
|
||||
def editor_user_ids(self, thread_id, user_ids=None):
|
||||
"""Return user ids with full edit rights on ``thread_id``.
|
||||
|
||||
Inverse of :meth:`editable_by`: starts from a thread and returns the
|
||||
set of users qualifying as editors (via any mailbox sharing the
|
||||
thread). When ``user_ids`` is provided, the result is narrowed to
|
||||
that subset — callers can batch-validate a list of candidate
|
||||
assignees in a single query and diff against the input.
|
||||
"""
|
||||
"""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
|
||||
# ``distinct()`` is required: a user reachable through several mailboxes
|
||||
# sharing the thread is joined once per path and would otherwise be
|
||||
# returned multiple times.
|
||||
|
||||
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)
|
||||
@@ -1714,14 +1704,25 @@ class ThreadEvent(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -682,6 +682,27 @@ def cleanup_assignments_on_thread_access_delete(sender, instance, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=models.ThreadAccess)
|
||||
def cleanup_mentions_on_thread_access_delete(sender, instance, **kwargs):
|
||||
"""Remove MENTION notifications for users of this mailbox when their
|
||||
ThreadAccess is deleted.
|
||||
|
||||
Only triggers on delete — downgrades between ThreadAccess roles
|
||||
(EDITOR ↔ VIEWER) keep the user able to read the thread, so their
|
||||
mentions stay valid.
|
||||
"""
|
||||
try:
|
||||
user_ids = list(instance.mailbox.accesses.values_list("user_id", flat=True))
|
||||
cleanup_invalid_mentions(instance.thread, user_ids)
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error cleaning mentions on ThreadAccess delete %s: %s",
|
||||
instance.pk,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_threads_for_mailbox_user(mailbox_id, user_id):
|
||||
"""Cleanup assignments of ``user_id`` across threads of ``mailbox_id``.
|
||||
|
||||
@@ -698,6 +719,77 @@ def _cleanup_threads_for_mailbox_user(mailbox_id, user_id):
|
||||
cleanup_invalid_assignments(thread, [user_id])
|
||||
|
||||
|
||||
def cleanup_invalid_mentions(thread, user_ids):
|
||||
"""Remove UserEvent MENTION rows for users that lost access to ``thread``.
|
||||
|
||||
Called from the access-change signals (ThreadAccess / MailboxAccess
|
||||
delete). Among ``user_ids``, keeps only those currently holding a
|
||||
MENTION notification on the thread *and* no longer reaching it through
|
||||
any ``ThreadAccess`` × ``MailboxAccess`` path, then deletes their
|
||||
MENTION rows.
|
||||
|
||||
Unlike :func:`cleanup_invalid_assignments`, no system ``ThreadEvent`` is
|
||||
recorded: the ``ThreadEvent IM`` rows containing the mentions are the
|
||||
historical source of truth and stay untouched; only the per-user
|
||||
notifications are removed.
|
||||
|
||||
A user reachable through multiple mailboxes keeps their mentions as
|
||||
long as at least one path still grants access, regardless of role —
|
||||
any ``MailboxAccess`` role is sufficient to view a shared thread.
|
||||
"""
|
||||
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 _cleanup_mentions_for_mailbox_user(mailbox_id, user_id):
|
||||
"""Cleanup MENTION notifications of ``user_id`` across threads of ``mailbox_id``.
|
||||
|
||||
Narrows to threads where the user actually holds a MENTION to avoid
|
||||
iterating over the full set of threads shared with the mailbox.
|
||||
"""
|
||||
threads = models.Thread.objects.filter(
|
||||
accesses__mailbox_id=mailbox_id,
|
||||
user_events__user_id=user_id,
|
||||
user_events__type=enums.UserEventTypeChoices.MENTION,
|
||||
).distinct()
|
||||
for thread in threads:
|
||||
cleanup_invalid_mentions(thread, [user_id])
|
||||
|
||||
|
||||
@receiver(post_save, sender=models.MailboxAccess)
|
||||
def cleanup_assignments_on_mailbox_access_downgrade(
|
||||
sender, instance, created, **kwargs
|
||||
@@ -735,6 +827,26 @@ def cleanup_assignments_on_mailbox_access_delete(sender, instance, **kwargs):
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=models.MailboxAccess)
|
||||
def cleanup_mentions_on_mailbox_access_delete(sender, instance, **kwargs):
|
||||
"""Remove MENTION notifications for this user across threads of the
|
||||
mailbox they just left.
|
||||
|
||||
Only triggers on delete — role changes between MailboxAccess roles
|
||||
(VIEWER/EDITOR/SENDER/ADMIN) all keep the user able to read threads
|
||||
shared with the mailbox, so their mentions stay valid.
|
||||
"""
|
||||
try:
|
||||
_cleanup_mentions_for_mailbox_user(instance.mailbox_id, instance.user_id)
|
||||
# pylint: disable=broad-exception-caught
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Error cleaning mentions on MailboxAccess delete %s: %s",
|
||||
instance.pk,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -209,7 +209,14 @@ class TestMailboxListByUser:
|
||||
)
|
||||
|
||||
result = response.json()["results"][0]
|
||||
assert set(result.keys()) == {"id", "email", "name", "role", "users", "is_identity"}
|
||||
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."""
|
||||
|
||||
@@ -95,11 +95,15 @@ class TestThreadAccessList:
|
||||
)
|
||||
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
|
||||
@@ -120,13 +124,13 @@ class TestThreadAccessList:
|
||||
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",
|
||||
@@ -874,13 +878,13 @@ class TestThreadAccessDetailUsersField:
|
||||
|
||||
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).
|
||||
viewers (they cannot be assignees). It is served only by the thread
|
||||
accesses list endpoint so thread list/retrieve payloads stay lean.
|
||||
"""
|
||||
|
||||
def _get_thread_detail(self, api_client, thread_id, mailbox_id):
|
||||
"""GET /api/v1.0/threads/{id}/?mailbox_id=..."""
|
||||
url = reverse("threads-detail", kwargs={"pk": thread_id})
|
||||
return api_client.get(f"{url}?mailbox_id={mailbox_id}")
|
||||
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."""
|
||||
@@ -917,10 +921,10 @@ class TestThreadAccessDetailUsersField:
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=requester)
|
||||
response = self._get_thread_detail(api_client, thread.id, mailbox.id)
|
||||
response = self._list_thread_accesses(api_client, thread.id)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
access = response.data["accesses"][0]
|
||||
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
|
||||
@@ -956,9 +960,9 @@ class TestThreadAccessDetailUsersField:
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=requester)
|
||||
response = self._get_thread_detail(api_client, thread.id, mailbox.id)
|
||||
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["accesses"][0]["users"]]
|
||||
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):
|
||||
@@ -998,12 +1002,10 @@ class TestThreadAccessDetailUsersField:
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=requester)
|
||||
response = self._get_thread_detail(api_client, thread.id, mailbox_a.id)
|
||||
response = self._list_thread_accesses(api_client, thread.id)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
accesses_by_mailbox = {
|
||||
str(a["mailbox"]["id"]): a for a in response.data["accesses"]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
@@ -1014,4 +1016,44 @@ class TestThreadAccessDetailUsersField:
|
||||
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
|
||||
assert str(b_only.id) not in a_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
|
||||
|
||||
@@ -133,6 +133,30 @@ class TestThreadEventCreate:
|
||||
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()
|
||||
@@ -615,31 +639,30 @@ class TestThreadEventDataValidation:
|
||||
assert "data" in response.data
|
||||
|
||||
def test_serializer_validate_dispatches_per_type(self, api_client):
|
||||
"""Invalid payloads are rejected at the serializer layer with
|
||||
field-level error keys, proving ``ThreadEventSerializer.validate()``
|
||||
routes ``data`` to the right sub-serializer for each event type
|
||||
before the model's ``full_clean()`` would catch it."""
|
||||
"""Invalid payloads are rejected at the serializer layer and cite the
|
||||
offending property, proving ``ThreadEventSerializer.validate()``
|
||||
routes ``data`` through ``ThreadEvent.validate_data`` for each event
|
||||
type before the model's ``full_clean()`` would catch it."""
|
||||
user, _mailbox, thread = setup_user_with_thread_access()
|
||||
api_client.force_authenticate(user=user)
|
||||
|
||||
# IM without 'content' → ThreadEventIMDataSerializer flags 'content'
|
||||
# IM without 'content' → schema flags 'content'
|
||||
response = api_client.post(
|
||||
get_thread_event_url(thread.id),
|
||||
{"type": "im", "data": {}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "content" in response.data["data"]
|
||||
assert "content" in str(response.data["data"])
|
||||
|
||||
# ASSIGN without 'assignees' → ThreadEventAssigneesDataSerializer
|
||||
# flags 'assignees' (same sub-serializer reused for UNASSIGN)
|
||||
# ASSIGN without 'assignees' → same schema path, same wiring as UNASSIGN
|
||||
response = api_client.post(
|
||||
get_thread_event_url(thread.id),
|
||||
{"type": "assign", "data": {}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "assignees" in response.data["data"]
|
||||
assert "assignees" in str(response.data["data"])
|
||||
|
||||
|
||||
def get_read_mention_url(thread_id, event_id):
|
||||
@@ -1185,6 +1208,85 @@ class TestThreadEventUnassign:
|
||||
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_created_at(assign_event, timezone.now() - timedelta(minutes=10))
|
||||
|
||||
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.
|
||||
@@ -1432,3 +1534,68 @@ class TestThreadEventUnassignUndoWindow:
|
||||
).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()
|
||||
|
||||
@@ -313,3 +313,85 @@ class TestThreadStatsAssignment:
|
||||
|
||||
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)]
|
||||
|
||||
@@ -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 @@ class TestThreadUserListResponse:
|
||||
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 @@ class TestThreadUserListIsolation:
|
||||
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
|
||||
|
||||
@@ -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 @@ from core.factories import (
|
||||
ThreadAccessFactory,
|
||||
ThreadEventFactory,
|
||||
ThreadFactory,
|
||||
UserEventFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import MailboxAccess, Thread
|
||||
@@ -1837,3 +1840,84 @@ class TestThreadListEventsCount:
|
||||
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}"
|
||||
)
|
||||
|
||||
@@ -81,19 +81,15 @@ class TestThreadEventAssignSchema:
|
||||
data={"content": "hello"},
|
||||
)
|
||||
|
||||
def test_assign_with_non_uuid_id_passes_clean(self):
|
||||
"""ThreadEvent type=assign with non-UUID id passes full_clean().
|
||||
|
||||
jsonschema format 'uuid' is not enforced by default;
|
||||
actual UUID validation is done in the signal helper.
|
||||
"""
|
||||
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()
|
||||
|
||||
event = factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
type="assign",
|
||||
data={"assignees": [{"id": "not-uuid", "name": "X"}]},
|
||||
)
|
||||
assert event.id is not None
|
||||
with pytest.raises(ValidationError):
|
||||
factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
type="assign",
|
||||
data={"assignees": [{"id": "not-uuid", "name": "X"}]},
|
||||
)
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import enums, factories, models
|
||||
from core.signals import handle_thread_event_post_save
|
||||
from core.signals import (
|
||||
create_assign_user_events,
|
||||
delete_assign_user_events,
|
||||
handle_thread_event_post_save,
|
||||
)
|
||||
from core.utils import ThreadStatsUpdateDeferrer
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -845,20 +850,26 @@ class TestAssignUserEvents:
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
def test_assign_skips_invalid_uuid(self):
|
||||
"""Assignee with invalid UUID should not create a UserEvent."""
|
||||
author, _, thread, _ = self._setup_thread_with_assigned_user()
|
||||
"""``create_assign_user_events`` is idempotent on malformed ids."""
|
||||
author, target_user, thread, _ = self._setup_thread_with_assigned_user()
|
||||
event = factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
type="assign",
|
||||
data={"assignees": [{"id": str(target_user.id), "name": "Target"}]},
|
||||
)
|
||||
initial_count = models.UserEvent.objects.filter(
|
||||
type=enums.UserEventTypeChoices.ASSIGN,
|
||||
).count()
|
||||
|
||||
with patch("core.signals.logger") as mock_logger:
|
||||
factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
type="assign",
|
||||
data={"assignees": [{"id": "not-a-valid-uuid", "name": "Bad"}]},
|
||||
created = create_assign_user_events(
|
||||
event,
|
||||
thread,
|
||||
[{"id": "not-a-valid-uuid", "name": "Bad"}],
|
||||
)
|
||||
|
||||
assert created == []
|
||||
assert (
|
||||
models.UserEvent.objects.filter(
|
||||
type=enums.UserEventTypeChoices.ASSIGN,
|
||||
@@ -1053,20 +1064,17 @@ class TestDeleteAssignUserEvents:
|
||||
mock_delete.assert_called_once()
|
||||
|
||||
def test_unassign_with_invalid_uuid_logs_warning(self):
|
||||
"""UNASSIGN with invalid UUID should log warning and update 0 records."""
|
||||
author, _, thread, _ = self._setup_thread_with_assigned_user()
|
||||
"""``delete_assign_user_events`` logs and skips malformed ids."""
|
||||
_, _, thread, _ = self._setup_thread_with_assigned_user()
|
||||
|
||||
with patch("core.signals.logger") as mock_logger:
|
||||
factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
type="unassign",
|
||||
data={
|
||||
"assignees": [
|
||||
{"id": "not-a-valid-uuid", "name": "Bad"},
|
||||
]
|
||||
},
|
||||
deleted = delete_assign_user_events(
|
||||
None,
|
||||
thread,
|
||||
[{"id": "not-a-valid-uuid", "name": "Bad"}],
|
||||
)
|
||||
|
||||
assert deleted == 0
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
|
||||
@@ -1225,3 +1233,186 @@ class TestCleanupInvalidAssignments:
|
||||
thread=thread,
|
||||
type=enums.ThreadEventTypeChoices.UNASSIGN,
|
||||
).exists()
|
||||
|
||||
|
||||
class TestCleanupInvalidMentions:
|
||||
"""Auto-clean UserEvent MENTION rows when a user loses access to the thread.
|
||||
|
||||
MENTION notifications are only valid as long as the user can still
|
||||
reach the thread through any ``ThreadAccess`` × ``MailboxAccess``
|
||||
path, regardless of role. Unlike ASSIGN cleanup, no system
|
||||
ThreadEvent is recorded — the ``ThreadEvent IM`` rows carrying the
|
||||
mentions remain as historical truth.
|
||||
"""
|
||||
|
||||
def _setup_mentioned_user(self):
|
||||
"""Set up a thread with a user mentioned in an IM event.
|
||||
|
||||
Returns (author, mentioned_user, thread, mailbox, mention_event).
|
||||
Both users share one mailbox with any role able to view the thread.
|
||||
"""
|
||||
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"}],
|
||||
},
|
||||
)
|
||||
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):
|
||||
"""Check the UserEvent MENTION is gone and no system event is recorded."""
|
||||
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_cleanup_on_thread_access_delete(self):
|
||||
"""Deleting the ThreadAccess removes MENTIONs of every user of that mailbox."""
|
||||
_author, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user()
|
||||
models.ThreadAccess.objects.filter(thread=thread, mailbox=mailbox).delete()
|
||||
self._assert_mention_cleaned(thread, mentioned_user)
|
||||
|
||||
def test_cleanup_on_mailbox_access_delete(self):
|
||||
"""Removing the user's MailboxAccess removes their MENTIONs."""
|
||||
_author, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user()
|
||||
models.MailboxAccess.objects.filter(
|
||||
user=mentioned_user, mailbox=mailbox
|
||||
).delete()
|
||||
self._assert_mention_cleaned(thread, mentioned_user)
|
||||
|
||||
def test_no_cleanup_on_thread_access_downgrade(self):
|
||||
"""ThreadAccess role EDITOR -> VIEWER keeps MENTIONs (user still reads)."""
|
||||
_author, mentioned_user, thread, mailbox, _ = self._setup_mentioned_user()
|
||||
access = models.ThreadAccess.objects.get(thread=thread, mailbox=mailbox)
|
||||
access.role = enums.ThreadAccessRoleChoices.VIEWER
|
||||
access.save()
|
||||
assert models.UserEvent.objects.filter(
|
||||
user=mentioned_user,
|
||||
thread=thread,
|
||||
type=enums.UserEventTypeChoices.MENTION,
|
||||
).exists()
|
||||
|
||||
def test_no_cleanup_on_mailbox_access_downgrade(self):
|
||||
"""MailboxAccess role change inside the role set keeps MENTIONs."""
|
||||
_author, 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()
|
||||
assert models.UserEvent.objects.filter(
|
||||
user=mentioned_user,
|
||||
thread=thread,
|
||||
type=enums.UserEventTypeChoices.MENTION,
|
||||
).exists()
|
||||
|
||||
def test_cleanup_skips_user_still_reaching_thread_via_other_mailbox(self):
|
||||
"""No cleanup when the user still has any access through another mailbox."""
|
||||
_author, 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,
|
||||
)
|
||||
|
||||
models.MailboxAccess.objects.filter(
|
||||
user=mentioned_user, mailbox=mailbox
|
||||
).delete()
|
||||
|
||||
assert models.UserEvent.objects.filter(
|
||||
user=mentioned_user,
|
||||
thread=thread,
|
||||
type=enums.UserEventTypeChoices.MENTION,
|
||||
).exists()
|
||||
|
||||
def test_cleanup_removes_read_and_unread_mentions(self):
|
||||
"""Both read and unread MENTION rows are removed on access loss."""
|
||||
author, mentioned_user, thread, mailbox, first_event = (
|
||||
self._setup_mentioned_user()
|
||||
)
|
||||
# Create a second mention and mark the first one as read.
|
||||
factories.ThreadEventFactory(
|
||||
thread=thread,
|
||||
author=author,
|
||||
data={
|
||||
"content": "Ping again @John",
|
||||
"mentions": [{"id": str(mentioned_user.id), "name": "John"}],
|
||||
},
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
models.ThreadAccess.objects.filter(thread=thread, mailbox=mailbox).delete()
|
||||
|
||||
assert not models.UserEvent.objects.filter(
|
||||
user=mentioned_user,
|
||||
thread=thread,
|
||||
type=enums.UserEventTypeChoices.MENTION,
|
||||
).exists()
|
||||
|
||||
def test_cleanup_no_op_when_user_not_mentioned(self):
|
||||
"""Deleting a ThreadAccess whose users have no MENTION triggers nothing."""
|
||||
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,
|
||||
)
|
||||
|
||||
models.ThreadAccess.objects.filter(thread=thread, mailbox=mailbox).delete()
|
||||
|
||||
assert not models.UserEvent.objects.filter(
|
||||
thread=thread,
|
||||
type=enums.UserEventTypeChoices.MENTION,
|
||||
).exists()
|
||||
|
||||
@@ -8,6 +8,9 @@ from contextlib import contextmanager
|
||||
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 ThreadStatsUpdateDeferrer:
|
||||
"""
|
||||
Manages deferred thread.update_stats() calls.
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
{
|
||||
"{{assignees}} was unassigned_one": "{{assignees}} was unassigned",
|
||||
"{{assignees}} was unassigned_other": "{{assignees}} were unassigned",
|
||||
"{{author}} adjusted assignments": "{{author}} adjusted assignments",
|
||||
"{{author}} assigned {{assignees}}_one": "{{author}} assigned {{assignees}}",
|
||||
"{{author}} assigned {{assignees}}_other": "{{author}} assigned {{assignees}}",
|
||||
"{{author}} modified assignments": "{{author}} modified assignments",
|
||||
"{{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",
|
||||
@@ -19,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.",
|
||||
@@ -41,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.",
|
||||
@@ -59,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",
|
||||
@@ -205,21 +229,6 @@
|
||||
"Collapse all": "Collapse all",
|
||||
"Color: ": "Color: ",
|
||||
"Coming soon": "Coming soon",
|
||||
"components.share.access.delete": "components.share.access.delete",
|
||||
"components.share.cannot_view.message": "components.share.cannot_view.message",
|
||||
"components.share.invitations.title": "components.share.invitations.title",
|
||||
"components.share.item.add": "components.share.item.add",
|
||||
"components.share.members.load_more": "components.share.members.load_more",
|
||||
"components.share.members.title_plural_one": "components.share.members.title_plural",
|
||||
"components.share.members.title_plural_other": "components.share.members.title_plural",
|
||||
"components.share.members.title_singular_one": "components.share.members.title_singular",
|
||||
"components.share.members.title_singular_other": "components.share.members.title_singular",
|
||||
"components.share.modalAriaLabel": "components.share.modalAriaLabel",
|
||||
"components.share.modalTitle": "components.share.modalTitle",
|
||||
"components.share.search.group_name": "components.share.search.group_name",
|
||||
"components.share.shareButton": "components.share.shareButton",
|
||||
"components.share.user.no_result": "components.share.user.no_result",
|
||||
"components.share.user.placeholder": "components.share.user.placeholder",
|
||||
"Conflicting": "Conflicting",
|
||||
"Contact the Support team": "Contact the Support team",
|
||||
"Contains the words": "Contains the words",
|
||||
@@ -363,8 +372,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 access and assign": "Grant access and assign",
|
||||
"Grant editor access to the mailbox?": "Grant editor access to the mailbox?",
|
||||
"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.",
|
||||
@@ -498,6 +506,7 @@
|
||||
"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",
|
||||
@@ -518,12 +527,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",
|
||||
@@ -566,8 +577,8 @@
|
||||
"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, {{sharedCount}} of which are shared_one": "Shared between {{count}} mailbox, {{sharedCount}} of which are shared",
|
||||
"Shared between {{count}} mailboxes, {{sharedCount}} of which are shared_other": "Shared between {{count}} mailboxes, {{sharedCount}} of which are shared",
|
||||
"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",
|
||||
@@ -627,7 +638,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}}, the mailbox must be granted editor access on this thread.": "The mailbox \"{{mailbox}}\" currently has read-only access on this thread. To assign {{user}}, the mailbox must be granted editor access on this thread.",
|
||||
"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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "The personal mailbox <1>{{mailboxAddress}}</1> has been created successfully.",
|
||||
@@ -655,6 +666,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.",
|
||||
@@ -711,6 +723,11 @@
|
||||
"You": "You",
|
||||
"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.",
|
||||
@@ -723,6 +740,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."
|
||||
|
||||
@@ -2,14 +2,39 @@
|
||||
"{{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}} adjusted assignments": "{{author}} a ajusté les assignations",
|
||||
"{{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}} modified assignments": "{{author}} a modifié les assignations",
|
||||
"{{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",
|
||||
@@ -28,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.",
|
||||
@@ -61,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.",
|
||||
@@ -88,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",
|
||||
@@ -259,23 +293,6 @@
|
||||
"Collapse all": "Tout réduire",
|
||||
"Color: ": "Couleur : ",
|
||||
"Coming soon": "Bientôt disponible",
|
||||
"components.share.access.delete": "",
|
||||
"components.share.cannot_view.message": "",
|
||||
"components.share.invitations.title": "",
|
||||
"components.share.item.add": "",
|
||||
"components.share.members.load_more": "",
|
||||
"components.share.members.title_plural_one": "",
|
||||
"components.share.members.title_plural_many": "",
|
||||
"components.share.members.title_plural_other": "",
|
||||
"components.share.members.title_singular_one": "",
|
||||
"components.share.members.title_singular_many": "",
|
||||
"components.share.members.title_singular_other": "",
|
||||
"components.share.modalAriaLabel": "",
|
||||
"components.share.modalTitle": "",
|
||||
"components.share.search.group_name": "",
|
||||
"components.share.shareButton": "",
|
||||
"components.share.user.no_result": "",
|
||||
"components.share.user.placeholder": "",
|
||||
"Conflicting": "En conflit",
|
||||
"Contact the Support team": "Écrire à l'équipe support",
|
||||
"Contains the words": "Contient les mots",
|
||||
@@ -423,8 +440,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 access and assign": "Accorder l'accès et assigner",
|
||||
"Grant editor access to the mailbox?": "Accorder l'accès en édition à la boîte ?",
|
||||
"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.",
|
||||
@@ -562,6 +578,7 @@
|
||||
"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",
|
||||
@@ -582,12 +599,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",
|
||||
@@ -631,9 +650,9 @@
|
||||
"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, {{sharedCount}} of which are shared_one": "Partagé entre {{count}} boîte mail, dont {{sharedCount}} partagée",
|
||||
"Shared between {{count}} mailboxes, {{sharedCount}} of which are shared_many": "Partagé entre {{count}} boîtes mails, dont {{sharedCount}} partagées",
|
||||
"Shared between {{count}} mailboxes, {{sharedCount}} of which are shared_other": "Partagé entre {{count}} boîtes mails, dont {{sharedCount}} partagées",
|
||||
"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",
|
||||
@@ -695,7 +714,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}}, the mailbox must be granted editor access on this thread.": "La boîte « {{mailbox}} » n'a actuellement que les droits de lecture sur ce fil. Pour y assigner {{user}}, la boîte doit être passée en édition sur ce fil.",
|
||||
"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 <strong>{{mailboxAddress}}</strong> has been created successfully.": "L'adresse personnelle <strong>{{mailboxAddress}}</strong> a été créée avec succès.",
|
||||
@@ -724,6 +743,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.",
|
||||
@@ -744,7 +764,7 @@
|
||||
"Unable to copy credentials.": "Impossible de copier les identifiants.",
|
||||
"Unable to copy to clipboard.": "Impossible de copier dans le presse-papiers.",
|
||||
"Unarchive": "Désarchiver",
|
||||
"Unassigned": "Non assignés",
|
||||
"Unassigned": "Non assigné",
|
||||
"Undelete": "Restaurer",
|
||||
"Undo": "Annuler",
|
||||
"Unfold message": "Développer le message",
|
||||
@@ -780,6 +800,13 @@
|
||||
"You": "Vous",
|
||||
"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.",
|
||||
@@ -790,9 +817,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."
|
||||
|
||||
@@ -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";
|
||||
@@ -152,6 +151,8 @@ 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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import type { MailboxLight } from "./mailbox_light";
|
||||
import type { ThreadAccessRoleChoices } from "./thread_access_role_choices";
|
||||
import type { UserWithoutAbilities } from "./user_without_abilities";
|
||||
|
||||
/**
|
||||
* Serializer for thread access details.
|
||||
@@ -21,5 +20,4 @@ export interface ThreadAccessDetail {
|
||||
readonly read_at: string | null;
|
||||
/** @nullable */
|
||||
readonly starred_at: string | null;
|
||||
readonly users: readonly UserWithoutAbilities[];
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ import type { ThreadEventUser } from "./thread_event_user";
|
||||
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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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[];
|
||||
|
||||
@@ -13,9 +13,10 @@ import type { ThreadEventUserRequest } from "./thread_event_user_request";
|
||||
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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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[];
|
||||
|
||||
@@ -10,9 +10,10 @@ 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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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;
|
||||
|
||||
@@ -10,9 +10,10 @@ 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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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 */
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
* 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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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;
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
* 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.clean()`` against
|
||||
``ThreadEvent.DATA_SCHEMAS``); exists solely to produce a named component
|
||||
in the OpenAPI schema consumed by the generated frontend client.
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval 🍺
|
||||
* Do not edit manually.
|
||||
* messages API
|
||||
* This is the messages API schema.
|
||||
* OpenAPI spec version: 1.0.0 (v1.0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get custom attributes for the instance.
|
||||
*/
|
||||
export type ThreadMentionableUserCustomAttributes = { [key: string]: unknown };
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import type {
|
||||
PaginatedThreadAccessList,
|
||||
PatchedThreadAccessRequest,
|
||||
ThreadAccess,
|
||||
ThreadAccessRequest,
|
||||
@@ -42,7 +41,7 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
* ViewSet for ThreadAccess model.
|
||||
*/
|
||||
export type threadsAccessesListResponse200 = {
|
||||
data: PaginatedThreadAccessList;
|
||||
data: ThreadAccess[];
|
||||
status: 200;
|
||||
};
|
||||
|
||||
|
||||
@@ -215,14 +215,14 @@ export function useThreadsEventsList<
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ThreadEvent with idempotence for ASSIGN and UNASSIGN.
|
||||
* Create a ThreadEvent
|
||||
|
||||
For ASSIGN (per D-08): if all assignees already have a UserEvent ASSIGN
|
||||
on this thread, return 200 without creating a ThreadEvent. If some are
|
||||
new, filter data.assignees to new ones only and create.
|
||||
For ASSIGN: if all assignees already have a UserEvent ASSIGN
|
||||
on this thread, return 204 without creating a ThreadEvent. If some
|
||||
are new, filter data.assignees to new ones only and create.
|
||||
|
||||
For UNASSIGN: if no assignee has a UserEvent ASSIGN on this thread,
|
||||
return 200 without creating a ThreadEvent.
|
||||
return 204 without creating a ThreadEvent.
|
||||
*/
|
||||
export type threadsEventsCreateResponse201 = {
|
||||
data: ThreadEvent;
|
||||
|
||||
@@ -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<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
* List distinct users who have access to a thread (via ThreadAccess → Mailbox → MailboxAccess).
|
||||
*/
|
||||
export type threadsUsersListResponse200 = {
|
||||
data: UserWithoutAbilities[];
|
||||
data: ThreadMentionableUser[];
|
||||
status: 200;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{thread.assigned_users.length > 0 && (
|
||||
<Tooltip
|
||||
content={t('Assigned to {{names}}', {
|
||||
names: thread.assigned_users.map((u) => u.name).join(', '),
|
||||
})}
|
||||
>
|
||||
<span
|
||||
aria-label={t('Assigned to {{names}}', {
|
||||
names: thread.assigned_users.map((u) => u.name).join(', '),
|
||||
})}
|
||||
>
|
||||
<AssigneesAvatarGroup
|
||||
users={thread.assigned_users}
|
||||
maxAvatars={2}
|
||||
overflowMode="replace-last"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-item__row">
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,25 +7,4 @@
|
||||
border-radius: var(--c--globals--border-radius--sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--c--globals--colors--greyscale--050);
|
||||
border-color: var(--c--globals--colors--greyscale--200);
|
||||
}
|
||||
|
||||
&__avatars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
> * + * {
|
||||
margin-left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
&__overflow {
|
||||
margin-left: 2px;
|
||||
font-size: var(--c--globals--typography--font-size--xs);
|
||||
font-weight: 600;
|
||||
color: var(--c--globals--colors--greyscale--700);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Tooltip } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAssignedUsers } from "@/features/message/use-assigned-users";
|
||||
import { useIsSharedContext } from "@/hooks/use-is-shared-context";
|
||||
import { AssigneesAvatarGroup } from "@/features/ui/components/assignees-avatar-group";
|
||||
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 3;
|
||||
|
||||
type AssigneesWidgetProps = {
|
||||
onClick: () => void;
|
||||
@@ -25,29 +25,22 @@ export const AssigneesWidget = ({ onClick }: AssigneesWidgetProps) => {
|
||||
if (!isSharedContext) return null;
|
||||
if (assignedUsers.length === 0) return null;
|
||||
|
||||
const visible = assignedUsers.slice(0, MAX_VISIBLE_AVATARS);
|
||||
const overflow = assignedUsers.length - visible.length;
|
||||
const tooltipContent = t('Assigned to {{names}}', {
|
||||
names: assignedUsers.map((u) => u.name).join(', '),
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipContent}>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
className="assignees-widget"
|
||||
onClick={onClick}
|
||||
aria-label={tooltipContent}
|
||||
aria-label={tooltipContent}
|
||||
size="nano"
|
||||
>
|
||||
<span className="assignees-widget__avatars">
|
||||
{visible.map((user) => (
|
||||
<UserAvatar key={user.id} fullName={user.name} size="xsmall" />
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="assignees-widget__overflow">+{overflow}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<AssigneesAvatarGroup users={assignedUsers} maxAvatars={3} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.share-modal-extensions__inline-assign {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
margin-right: var(--c--globals--spacings--xs);
|
||||
}
|
||||
&:hover .share-modal-extensions__inline-assign,
|
||||
&:focus-within .share-modal-extensions__inline-assign {
|
||||
|
||||
@@ -33,24 +33,26 @@ export const AccessRoleDropdown = ({
|
||||
onDelete,
|
||||
canDelete = true,
|
||||
}: AccessRoleDropdownProps) => {
|
||||
const { t } = useCunningham();
|
||||
// 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 && !canDelete) {
|
||||
if (!onDelete) {
|
||||
return roles;
|
||||
}
|
||||
return [
|
||||
...roles,
|
||||
{ type: "separator" as const },
|
||||
{
|
||||
label: t("components.share.access.delete"),
|
||||
label: tc("components.share.access.delete"),
|
||||
callback: onDelete,
|
||||
isDisabled: !canDelete,
|
||||
},
|
||||
];
|
||||
}, [roles, onDelete, t, canDelete]);
|
||||
}, [roles, onDelete, tc, canDelete]);
|
||||
|
||||
if (!canUpdate) {
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Spinner, UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ThreadAccessDetail, UserWithoutAbilities } from "@/features/api/gen";
|
||||
|
||||
type AccessWithUsers = ThreadAccessDetail & {
|
||||
users: readonly UserWithoutAbilities[];
|
||||
};
|
||||
|
||||
type AccessUsersListProps = {
|
||||
access: ThreadAccessDetail;
|
||||
access: AccessWithUsers;
|
||||
assignedUserIds: ReadonlySet<string>;
|
||||
canAssign: boolean;
|
||||
onAssign: (user: UserWithoutAbilities, access: ThreadAccessDetail) => void;
|
||||
assigningUserId: string | null;
|
||||
onAssign: (user: UserWithoutAbilities, access: AccessWithUsers) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,6 +27,7 @@ export const AccessUsersList = ({
|
||||
access,
|
||||
assignedUserIds,
|
||||
canAssign,
|
||||
assigningUserId,
|
||||
onAssign,
|
||||
}: AccessUsersListProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,6 +39,7 @@ export const AccessUsersList = ({
|
||||
<ul className="share-modal-extensions__users">
|
||||
{access.users.map((user) => {
|
||||
const isAssigned = assignedUserIds.has(user.id);
|
||||
const isAssigning = assigningUserId === user.id;
|
||||
return (
|
||||
<li key={user.id} className="share-modal-extensions__users__item">
|
||||
<UserAvatar fullName={user.full_name || user.email || ""} size="small" />
|
||||
@@ -50,6 +57,8 @@ export const AccessUsersList = ({
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="share-modal-extensions__users__cta"
|
||||
disabled={isAssigning || assigningUserId !== null}
|
||||
icon={isAssigning ? <Spinner size="sm" /> : undefined}
|
||||
>
|
||||
{t('Assign')}
|
||||
</Button>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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<UserType> = {
|
||||
@@ -28,7 +29,9 @@ export const InvitationUserSelectorList = <UserType,>({
|
||||
selectedRole,
|
||||
onSelectRole,
|
||||
}: InvitationUserSelectorListProps<UserType>) => {
|
||||
const { t } = useCunningham();
|
||||
// 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 (
|
||||
<div className="c__add-share-user-list" data-testid="selected-users-list">
|
||||
@@ -53,7 +56,7 @@ export const InvitationUserSelectorList = <UserType,>({
|
||||
onDelete={undefined}
|
||||
/>
|
||||
<Button onClick={onShare}>
|
||||
{shareButtonLabel ?? t("components.share.shareButton")}
|
||||
{shareButtonLabel ?? tc("components.share.shareButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,10 +72,13 @@ const InvitationUserSelectorItem = <UserType,>({
|
||||
user,
|
||||
onRemoveUser,
|
||||
}: InvitationUserSelectorItemProps<UserType>) => {
|
||||
const { t } = useTranslation();
|
||||
const displayName = user.full_name || user.email;
|
||||
return (
|
||||
<div className="c__add-share-user-item" data-testid="selected-user-item">
|
||||
<span>{user.full_name || user.email}</span>
|
||||
<span>{displayName}</span>
|
||||
<Button
|
||||
aria-label={t("Remove {{displayName}}", { displayName })}
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
size="nano"
|
||||
|
||||
@@ -34,9 +34,11 @@ export const ShareMemberItem = <UserType, AccessType>({
|
||||
rightExtras,
|
||||
}: ShareMemberItemProps<UserType, AccessType>) => {
|
||||
const [isRoleOpen, setIsRoleOpen] = useState(false);
|
||||
const accessFlags = accessData as { is_explicit?: boolean; can_delete?: boolean };
|
||||
const canDelete =
|
||||
(accessData as { is_explicit?: boolean; can_delete?: boolean }).is_explicit !== false &&
|
||||
(accessData as { is_explicit?: boolean; can_delete?: boolean }).can_delete !== false;
|
||||
Boolean(deleteAccess) &&
|
||||
accessFlags.is_explicit !== false &&
|
||||
accessFlags.can_delete !== false;
|
||||
return (
|
||||
<div className="c__share-member-item">
|
||||
<QuickSearchItemTemplate
|
||||
|
||||
@@ -50,7 +50,9 @@ type SearchUserItemProps<UserType> = {
|
||||
|
||||
// Reproduced locally because ui-kit does not export it.
|
||||
const SearchUserItem = <UserType,>({ user }: SearchUserItemProps<UserType>) => {
|
||||
const { t } = useCunningham();
|
||||
// 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();
|
||||
return (
|
||||
<QuickSearchItemTemplate
|
||||
testId="search-user-item"
|
||||
@@ -58,7 +60,7 @@ const SearchUserItem = <UserType,>({ user }: SearchUserItemProps<UserType>) => {
|
||||
alwaysShowRight={false}
|
||||
right={
|
||||
<div className="c__search-user-item-right">
|
||||
<span>{t("components.share.item.add")}</span>
|
||||
<span>{tc("components.share.item.add")}</span>
|
||||
<span className="material-icons">add</span>
|
||||
</div>
|
||||
}
|
||||
@@ -118,6 +120,12 @@ type ShareModalSearchProps<UserType> = {
|
||||
searchPlaceholder?: string;
|
||||
onInviteUser?: (users: UserData<UserType>[], 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<UserType, InvitationType, AccessType> = {
|
||||
@@ -160,6 +168,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
renderAccessFooter,
|
||||
renderAccessRightExtras,
|
||||
membersTitle,
|
||||
allowInvitation = true,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
ShareModalProps<UserType, InvitationType, AccessType>
|
||||
@@ -179,7 +188,9 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
throw new Error("canView cannot be false if canUpdate is true");
|
||||
}
|
||||
|
||||
const { t } = useCunningham();
|
||||
// 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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [listHeight, setListHeight] = useState<string>("400px");
|
||||
@@ -235,13 +246,18 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
};
|
||||
|
||||
const usersData: QuickSearchData<UserData<UserType>> = useMemo(() => {
|
||||
// TODO(upstream): fix when contributing this fork back to ui-kit.
|
||||
// `.includes(user)` relies on reference equality; after a search refetch
|
||||
// the server returns freshly allocated objects, so already-pending users
|
||||
// reappear in the list and can be picked twice. Filter by `user.id`
|
||||
// instead (e.g. via a Set of pending ids).
|
||||
const searchMemberResult = searchUsersResult?.filter(
|
||||
(user) => !pendingInvitationUsers.includes(user),
|
||||
);
|
||||
let emptyString: string | undefined =
|
||||
searchQuery !== ""
|
||||
? t("components.share.user.no_result")
|
||||
: t("components.share.user.placeholder");
|
||||
? tc("components.share.user.no_result")
|
||||
: tc("components.share.user.placeholder");
|
||||
|
||||
const isValidEmail = (email: string) =>
|
||||
!!email.match(
|
||||
@@ -249,6 +265,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
);
|
||||
|
||||
const isInvitationMode =
|
||||
allowInvitation &&
|
||||
isValidEmail(searchQuery ?? "") &&
|
||||
!searchMemberResult?.some((user) => user.email === searchQuery);
|
||||
|
||||
@@ -263,7 +280,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
}
|
||||
|
||||
return {
|
||||
groupName: t("components.share.search.group_name"),
|
||||
groupName: tc("components.share.search.group_name"),
|
||||
elements: searchMemberResult ?? [],
|
||||
showWhenEmpty: true,
|
||||
emptyString,
|
||||
@@ -276,7 +293,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
]
|
||||
: undefined,
|
||||
} satisfies QuickSearchData<UserData<UserType>>;
|
||||
}, [searchUsersResult, searchQuery, t, pendingInvitationUsers, onSelect]);
|
||||
}, [searchUsersResult, searchQuery, tc, pendingInvitationUsers, onSelect, allowInvitation]);
|
||||
|
||||
const handleRef = (node: HTMLDivElement) => {
|
||||
const inputHeight = 70;
|
||||
@@ -305,10 +322,10 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={props.modalTitle ?? t("components.share.modalTitle")}
|
||||
title={props.modalTitle ?? tc("components.share.modalTitle")}
|
||||
isOpen={props.isOpen}
|
||||
onClose={props.onClose}
|
||||
aria-label={t("components.share.modalAriaLabel")}
|
||||
aria-label={tc("components.share.modalAriaLabel")}
|
||||
closeOnClickOutside
|
||||
size={isMobile ? ModalSize.FULL : ModalSize.LARGE}
|
||||
>
|
||||
@@ -343,7 +360,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
<div className="c__share-modal__cannot-view__content">
|
||||
<p>
|
||||
{props.cannotViewMessage ??
|
||||
t("components.share.cannot_view.message")}
|
||||
tc("components.share.cannot_view.message")}
|
||||
</p>
|
||||
</div>
|
||||
{cannotViewChildren}
|
||||
@@ -356,7 +373,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
inputValue={inputValue}
|
||||
showInput={canUpdate}
|
||||
loading={props.loading}
|
||||
placeholder={t("components.share.user.placeholder")}
|
||||
placeholder={tc("components.share.user.placeholder")}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -387,7 +404,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
data-testid="invitations-list"
|
||||
>
|
||||
<span className="c__share-modal__invitations-title">
|
||||
{t("components.share.invitations.title")}
|
||||
{tc("components.share.invitations.title")}
|
||||
</span>
|
||||
{invitations.map((invitation) => (
|
||||
<ShareInvitationItem
|
||||
@@ -417,7 +434,7 @@ export const ShareModal = <UserType, InvitationType, AccessType>({
|
||||
<span className="c__share-modal__members-title">
|
||||
{membersTitle
|
||||
? membersTitle(members)
|
||||
: t(
|
||||
: tc(
|
||||
members.length > 1
|
||||
? "components.share.members.title_plural"
|
||||
: "components.share.members.title_singular",
|
||||
@@ -467,7 +484,9 @@ type ShowMoreButtonProps = {
|
||||
};
|
||||
|
||||
const ShowMoreButton = ({ show, onShowMore }: ShowMoreButtonProps) => {
|
||||
const { t } = useCunningham();
|
||||
// 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 (
|
||||
<div className="c__share-modal__show-more-button">
|
||||
@@ -477,9 +496,8 @@ const ShowMoreButton = ({ show, onShowMore }: ShowMoreButtonProps) => {
|
||||
icon={<span className="material-icons">arrow_downward</span>}
|
||||
onClick={onShowMore}
|
||||
>
|
||||
{t("components.share.members.load_more")}
|
||||
{tc("components.share.members.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Icon, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
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,
|
||||
ThreadEventTypeEnum,
|
||||
UserWithoutAbilities,
|
||||
getThreadsAccessesListQueryKey,
|
||||
threadsAccessesListResponse,
|
||||
useMailboxesSearchList,
|
||||
useThreadsAccessesCreate,
|
||||
useThreadsAccessesDestroy,
|
||||
useThreadsAccessesList,
|
||||
useThreadsAccessesUpdate,
|
||||
useThreadsEventsCreate,
|
||||
} from "@/features/api/gen";
|
||||
@@ -20,7 +25,6 @@ import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { useIsSharedContext } from "@/hooks/use-is-shared-context";
|
||||
import { useAssignedUsers } from "@/features/message/use-assigned-users";
|
||||
import { AssignedUsersSection, AccessUsersList, ShareModal } from "../share-modal-extensions";
|
||||
import { UpgradeMailboxRoleModal } from "./upgrade-mailbox-role-modal";
|
||||
|
||||
export type ThreadAccessesWidgetHandle = {
|
||||
open: () => void;
|
||||
@@ -30,6 +34,17 @@ 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[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists thread accesses and lets users manage sharing + per-user assignment.
|
||||
* Exposes an `open()` handle so the `AssigneesWidget` (rendered inside
|
||||
@@ -45,11 +60,7 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
const { t } = useTranslation();
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [pendingUpgrade, setPendingUpgrade] = useState<{
|
||||
user: UserWithoutAbilities;
|
||||
access: ThreadAccessDetail;
|
||||
} | null>(null);
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
const [assigningUserId, setAssigningUserId] = useState<string | null>(null);
|
||||
const {
|
||||
selectedMailbox,
|
||||
selectedThread,
|
||||
@@ -60,28 +71,114 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
} = useMailboxContext();
|
||||
const modals = useModals();
|
||||
const assignedUsers = useAssignedUsers();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => setIsShareModalOpen(true),
|
||||
}), []);
|
||||
|
||||
const { mutate: removeThreadAccess } = useThreadsAccessesDestroy({
|
||||
mutation: { onSuccess: () => invalidateThreadMessages() },
|
||||
// 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<threadsAccessesListResponse>(
|
||||
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 { mutate: createThreadAccess } = useThreadsAccessesCreate({
|
||||
mutation: { onSuccess: () => invalidateThreadMessages() },
|
||||
const createMutation = useThreadsAccessesCreate({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
invalidateThreadMessages();
|
||||
if (selectedThread?.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getThreadsAccessesListQueryKey(selectedThread.id),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
const { mutate: updateThreadAccess } = useThreadsAccessesUpdate({
|
||||
mutation: { onSuccess: () => invalidateThreadMessages() },
|
||||
const updateMutation = useThreadsAccessesUpdate({
|
||||
mutation: {
|
||||
onSuccess: (data) => {
|
||||
invalidateThreadMessages();
|
||||
patchAccessesCache((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === data.data.id ? { ...a, role: data.data.role } : a,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { mutate: createThreadEvent } = useThreadsEventsCreate();
|
||||
|
||||
// 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 hasOnlyOneEditor = accesses.filter((a) => a.role === ThreadAccessRoleChoices.editor).length === 1;
|
||||
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/
|
||||
// (users per access). The endpoint is gated by manage rights; viewers
|
||||
// get nothing from /accesses/ and fall back to empty users arrays so
|
||||
// the modal still renders mailbox rows without the assignable-user
|
||||
// sub-list.
|
||||
const usersByAccessId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(threadAccessesQuery.data?.data ?? []).map((a) => [a.id, a.users]),
|
||||
),
|
||||
[threadAccessesQuery.data?.data],
|
||||
);
|
||||
const enrichedAccesses: readonly EnrichedAccess[] = accesses.map((access) => ({
|
||||
...access,
|
||||
users: usersByAccessId.get(access.id) ?? [],
|
||||
}));
|
||||
|
||||
const getAccessUser = (mailbox: MailboxLight) => ({
|
||||
...mailbox,
|
||||
full_name: mailbox.name,
|
||||
@@ -91,11 +188,7 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
.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 isAssignmentContext = useIsSharedContext();
|
||||
|
||||
const normalizedAccesses = accesses.map((access) => ({
|
||||
const normalizedAccesses = enrichedAccesses.map((access) => ({
|
||||
...access,
|
||||
user: getAccessUser(access.mailbox),
|
||||
can_delete: canManageThreadAccess && accesses.length > 1 && (!hasOnlyOneEditor || access.role !== ThreadAccessRoleChoices.editor),
|
||||
@@ -116,7 +209,7 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
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,
|
||||
@@ -127,8 +220,8 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateAccess = (access: ThreadAccessDetail, role: string) => {
|
||||
updateThreadAccess({
|
||||
const handleUpdateAccess = (access: EnrichedAccess, role: string) => {
|
||||
updateMutation.mutate({
|
||||
id: access.id,
|
||||
threadId: selectedThread!.id,
|
||||
data: {
|
||||
@@ -139,7 +232,7 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAccess = async (access: ThreadAccessDetail) => {
|
||||
const handleDeleteAccess = async (access: EnrichedAccess) => {
|
||||
if (hasOnlyOneEditor && access.role === ThreadAccessRoleChoices.editor) {
|
||||
addToast(
|
||||
<ToasterItem type="error">
|
||||
@@ -163,7 +256,7 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
),
|
||||
});
|
||||
if (decision !== 'delete') return;
|
||||
removeThreadAccess({
|
||||
removeMutation.mutate({
|
||||
id: access.id,
|
||||
threadId: selectedThread!.id,
|
||||
}, {
|
||||
@@ -186,7 +279,10 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchAssignEvent = (user: UserWithoutAbilities) => {
|
||||
const dispatchAssignEvent = (
|
||||
user: UserWithoutAbilities,
|
||||
options?: { onSettled?: () => void },
|
||||
) => {
|
||||
createThreadEvent({
|
||||
threadId: selectedThread!.id,
|
||||
data: {
|
||||
@@ -200,39 +296,43 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
await invalidateThreadEvents();
|
||||
await invalidateThreadsStats();
|
||||
},
|
||||
onSettled: () => options?.onSettled?.(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignUser = (user: UserWithoutAbilities, access: ThreadAccessDetail) => {
|
||||
const handleAssignUser = async (user: UserWithoutAbilities, access: EnrichedAccess) => {
|
||||
if (access.role === ThreadAccessRoleChoices.viewer) {
|
||||
setPendingUpgrade({ user, access });
|
||||
return;
|
||||
const decision = await modals.confirmationModal({
|
||||
title: <span className="c__modal__text--centered">{t('Grant editor access to the thread?')}</span>,
|
||||
children: (
|
||||
<span className="c__modal__text--centered">
|
||||
{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 || "" },
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
if (decision !== 'yes') return;
|
||||
setAssigningUserId(user.id);
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
id: access.id,
|
||||
threadId: selectedThread!.id,
|
||||
data: {
|
||||
thread: selectedThread!.id,
|
||||
mailbox: access.mailbox.id,
|
||||
role: ThreadAccessRoleChoices.editor,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setAssigningUserId(null);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setAssigningUserId(user.id);
|
||||
}
|
||||
dispatchAssignEvent(user);
|
||||
};
|
||||
|
||||
const handleConfirmUpgrade = () => {
|
||||
if (!pendingUpgrade) return;
|
||||
const { user, access } = pendingUpgrade;
|
||||
setIsUpgrading(true);
|
||||
updateThreadAccess({
|
||||
id: access.id,
|
||||
threadId: selectedThread!.id,
|
||||
data: {
|
||||
thread: selectedThread!.id,
|
||||
mailbox: access.mailbox.id,
|
||||
role: ThreadAccessRoleChoices.editor,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
dispatchAssignEvent(user);
|
||||
setPendingUpgrade(null);
|
||||
setIsUpgrading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setIsUpgrading(false);
|
||||
},
|
||||
});
|
||||
dispatchAssignEvent(user, { onSettled: () => setAssigningUserId(null) });
|
||||
};
|
||||
|
||||
const handleUnassignUser = (userId: string) => {
|
||||
@@ -268,10 +368,10 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
{accesses.length}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ShareModal<MailboxLight, MailboxLight, ThreadAccessDetail>
|
||||
<ShareModal<MailboxLight, MailboxLight, EnrichedAccess>
|
||||
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)}
|
||||
@@ -282,15 +382,24 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
onSearchUsers={setSearchQuery}
|
||||
searchUsersResult={searchResults}
|
||||
accesses={normalizedAccesses}
|
||||
allowInvitation={false}
|
||||
membersTitle={(members) => {
|
||||
const sharedCount = members.filter(
|
||||
(m) => (m.users?.length ?? 0) > 1 || m.mailbox.is_identity === false,
|
||||
).length;
|
||||
return t('Shared between {{count}} mailboxes, {{sharedCount}} of which are shared', {
|
||||
const total = t('Shared between {{count}} mailboxes', {
|
||||
count: members.length,
|
||||
sharedCount,
|
||||
defaultValue_one: 'Shared between {{count}} mailbox, {{sharedCount}} of which are shared',
|
||||
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) {
|
||||
@@ -303,10 +412,17 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
access={access}
|
||||
assignedUserIds={assignedUserIds}
|
||||
canAssign={canManageThreadAccess && isAssignmentContext}
|
||||
assigningUserId={assigningUserId}
|
||||
onAssign={handleAssignUser}
|
||||
/>
|
||||
)}
|
||||
renderAccessRightExtras={(access) => {
|
||||
// Show an inline spinner when a mutation is in flight
|
||||
// on this specific row (role change or removal) — the
|
||||
// ShareModal's own select can't convey "pending" state.
|
||||
if (isAccessPending(access.id) || isMailboxPending(access.mailbox.id)) {
|
||||
return <Spinner size="sm" />;
|
||||
}
|
||||
// Identity mailboxes with a single user collapse the
|
||||
// users sub-list into an inline "Assign" CTA on the row
|
||||
// itself. A mailbox is "shared" when is_identity is
|
||||
@@ -318,12 +434,15 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
if (!isAssignmentContext) return null;
|
||||
const user = access.users[0];
|
||||
if (assignedUserIds.has(user.id)) return null;
|
||||
const isAssigning = assigningUserId === user.id;
|
||||
return (
|
||||
<Button
|
||||
onClick={() => handleAssignUser(user, access)}
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="share-modal-extensions__inline-assign"
|
||||
disabled={isAssigning || assigningUserId !== null}
|
||||
icon={isAssigning ? <Spinner size="sm" /> : undefined}
|
||||
>
|
||||
{t('Assign')}
|
||||
</Button>
|
||||
@@ -345,17 +464,6 @@ export const ThreadAccessesWidget = forwardRef<ThreadAccessesWidgetHandle, Threa
|
||||
/>
|
||||
)}
|
||||
</ShareModal>
|
||||
<UpgradeMailboxRoleModal
|
||||
isOpen={pendingUpgrade !== null}
|
||||
onClose={() => {
|
||||
if (isUpgrading) return;
|
||||
setPendingUpgrade(null);
|
||||
}}
|
||||
onConfirm={handleConfirmUpgrade}
|
||||
user={pendingUpgrade?.user ?? null}
|
||||
access={pendingUpgrade?.access ?? null}
|
||||
isPending={isUpgrading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Button, Modal, ModalSize } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ThreadAccessDetail, UserWithoutAbilities } from "@/features/api/gen";
|
||||
|
||||
type UpgradeMailboxRoleModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
user: UserWithoutAbilities | null;
|
||||
access: ThreadAccessDetail | null;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export const UpgradeMailboxRoleModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
user,
|
||||
access,
|
||||
isPending,
|
||||
}: UpgradeMailboxRoleModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const userLabel = user?.full_name || user?.email || "";
|
||||
const mailboxLabel = access?.mailbox.email || "";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={t('Grant editor access to the mailbox?')}
|
||||
size={ModalSize.MEDIUM}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="upgrade-mailbox-role-modal">
|
||||
<p>
|
||||
{t(
|
||||
'The mailbox "{{mailbox}}" currently has read-only access on this thread. To assign {{user}}, the mailbox must be granted editor access on this thread.',
|
||||
{ mailbox: mailboxLabel, user: userLabel },
|
||||
)}
|
||||
</p>
|
||||
<footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
icon={isPending && <Spinner />}
|
||||
>
|
||||
{t('Grant access and assign')}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -70,10 +70,13 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="thread-action-bar__container">
|
||||
<div className="thread-action-bar">
|
||||
<AssigneesWidget onClick={() => accessesWidgetRef.current?.open()} />
|
||||
</div>
|
||||
<div className="thread-action-bar">
|
||||
<AssigneesWidget onClick={() => accessesWidgetRef.current?.open()} />
|
||||
<Tooltip content={t('Close this thread')} placement="left">
|
||||
<Tooltip content={t('Close this thread')} placement="bottom">
|
||||
<Button
|
||||
onClick={unselectThread}
|
||||
variant="tertiary"
|
||||
@@ -206,6 +209,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
/>
|
||||
</Tooltip>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +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, ThreadEventIMData } 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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { DropdownButton } from "@/features/ui/components/dropdown-button";
|
||||
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";
|
||||
@@ -28,8 +27,7 @@ type ThreadEventInputProps = {
|
||||
*/
|
||||
export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEventCreated, containerRef }: ThreadEventInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { invalidateThreadEvents, invalidateThreadsStats } = useMailboxContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateThreadEvents } = useMailboxContext();
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isMessageFormFocused } = useThreadViewContext();
|
||||
|
||||
@@ -48,7 +46,6 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
|
||||
const createEvent = useThreadsEventsCreate();
|
||||
const createAssignEvent = useThreadsEventsCreate();
|
||||
const updateEvent = useThreadsEventsPartialUpdate();
|
||||
const isPending = isEditing ? updateEvent.isPending : createEvent.isPending;
|
||||
|
||||
@@ -61,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;
|
||||
@@ -144,70 +141,12 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent
|
||||
}
|
||||
}, [content, mentions, threadId, editingEvent, isEditing, createEvent, updateEvent, invalidateThreadEvents, onEventCreated, onCancelEdit, processContent, buildEventData, resetInput]);
|
||||
|
||||
/**
|
||||
* Post the IM event first, then assign each mentioned user sequentially.
|
||||
* Backend idempotence (D-20) handles already-assigned users gracefully.
|
||||
*/
|
||||
const handleSubmitAndAssign = useCallback(() => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return;
|
||||
if (createEvent.isPending) return;
|
||||
|
||||
const processedContent = processContent(trimmed);
|
||||
const eventData = buildEventData(processedContent);
|
||||
|
||||
createEvent.mutate(
|
||||
{
|
||||
threadId,
|
||||
data: {
|
||||
type: ThreadEventTypeEnum.im,
|
||||
data: eventData,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
const activeMentions = mentions.filter((m) =>
|
||||
processedContent.includes(`@[${m.name}]`)
|
||||
);
|
||||
|
||||
if (activeMentions.length > 0) {
|
||||
try {
|
||||
await Promise.all(
|
||||
activeMentions.map((mention) =>
|
||||
createAssignEvent.mutateAsync({
|
||||
threadId,
|
||||
data: {
|
||||
type: ThreadEventTypeEnum.assign,
|
||||
data: {
|
||||
assignees: [
|
||||
{ id: mention.id, name: mention.name },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Assignment failures are non-critical -- the IM was already posted
|
||||
}
|
||||
}
|
||||
|
||||
resetInput();
|
||||
await invalidateThreadEvents();
|
||||
await invalidateThreadsStats();
|
||||
await queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
onEventCreated?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [content, mentions, threadId, createEvent, createAssignEvent, invalidateThreadEvents, invalidateThreadsStats, queryClient, onEventCreated, processContent, buildEventData, resetInput]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
resetInput();
|
||||
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)) {
|
||||
@@ -355,13 +294,20 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent
|
||||
itemToString={(item) => item?.full_name || item?.email || ""}
|
||||
keyExtractor={(user) => user.id}
|
||||
renderItem={(user) => (
|
||||
<UserRow
|
||||
fullName={user.full_name || undefined}
|
||||
email={user.email || undefined}
|
||||
/>
|
||||
<div className="thread-event-input__suggestion">
|
||||
<UserRow
|
||||
fullName={user.full_name || undefined}
|
||||
email={user.email || undefined}
|
||||
/>
|
||||
{!user.can_post_comments && (
|
||||
<Badge color="warning" variant="secondary">
|
||||
{t("Read-only")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<DropdownButton
|
||||
<Button
|
||||
className="thread-event-input__submit-button"
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
@@ -370,14 +316,6 @@ export const ThreadEventInput = ({ threadId, editingEvent, onCancelEdit, onEvent
|
||||
disabled={!content.trim() || isPending}
|
||||
title={isEditing ? t("Save") : t("Send")}
|
||||
aria-label={isEditing ? t("Save") : t("Send")}
|
||||
showDropdown={!isEditing && mentions.length > 0}
|
||||
dropdownOptions={[
|
||||
{
|
||||
label: t("Post and assign mentioned"),
|
||||
icon: <Icon name="person_add" type={IconType.OUTLINED} />,
|
||||
callback: handleSubmitAndAssign,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,19 +126,21 @@
|
||||
margin-left: var(--c--globals--spacings--3xs);
|
||||
}
|
||||
|
||||
// System events (ASSIGN/UNASSIGN) — compact centered line (D-14)
|
||||
.thread-event--system {
|
||||
// System events (ASSIGN/UNASSIGN) — single discrete text line, no icon, no
|
||||
// background, no centering. Aligned with the rest of the timeline so metadata
|
||||
// fades into the margin instead of competing with messages for attention.
|
||||
.thread-event--system-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base);
|
||||
align-items: baseline;
|
||||
gap: var(--c--globals--spacings--2xs);
|
||||
padding: var(--c--globals--spacings--3xs) 0;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.thread-event__system-text {
|
||||
font-style: italic;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
}
|
||||
|
||||
.thread-event__system-time {
|
||||
@@ -146,30 +148,50 @@
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
// Grouped assignment summary: +Name / -Name chips shown inline after the
|
||||
// system message when consecutive ASSIGN/UNASSIGN events by the same author
|
||||
// are collapsed.
|
||||
.thread-event__assignment-changes {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
// Collapsed run of 3+ non-IM events. Rendered as a progressive-disclosure
|
||||
// toggle that expands to the inline event lines.
|
||||
.thread-event--collapsed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--c--globals--spacings--2xs);
|
||||
margin-left: var(--c--globals--spacings--2xs);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.thread-event__assignment-change {
|
||||
font-weight: 600;
|
||||
.thread-event__collapsed-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--2xs);
|
||||
padding: var(--c--globals--spacings--3xs) var(--c--globals--spacings--2xs);
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--c--globals--spacings--3xs);
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
|
||||
&--added {
|
||||
color: var(--c--contextuals--content--semantic--success--primary);
|
||||
&:hover {
|
||||
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
|
||||
}
|
||||
|
||||
&--removed {
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
text-decoration: line-through;
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--c--contextuals--border--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.thread-event__collapsed-bucket {
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
.thread-event__collapsed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--c--globals--spacings--base);
|
||||
border-left: 2px solid var(--c--contextuals--border--default);
|
||||
}
|
||||
|
||||
// Generic event types (non-IM)
|
||||
.thread-event--generic {
|
||||
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--base);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AssignmentEvent, buildAssignmentMessage } from './assignment-message';
|
||||
import { ThreadEvent, ThreadEventTypeEnum } from '@/features/api/gen/models';
|
||||
|
||||
const SELF_ID = 'user-self';
|
||||
|
||||
// Minimal i18n stub: returns the key with {{x}} placeholders replaced. Good
|
||||
// enough to assert which branch was picked and what parameters it received.
|
||||
const fakeT: TFunction = ((key: string, params?: Record<string, unknown>) => {
|
||||
if (!params) return key;
|
||||
return Object.entries(params).reduce(
|
||||
(acc, [k, v]) => acc.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v)),
|
||||
key,
|
||||
);
|
||||
}) as unknown as TFunction;
|
||||
|
||||
const makeEvent = (
|
||||
type: 'assign' | 'unassign',
|
||||
authorId: string | null,
|
||||
assignees: { id: string; name: string }[],
|
||||
): AssignmentEvent => ({
|
||||
id: 'e',
|
||||
thread: 't',
|
||||
type,
|
||||
channel: null,
|
||||
author: authorId === null
|
||||
? (null as unknown as ThreadEvent['author'])
|
||||
: ({ id: authorId, full_name: `User ${authorId}`, email: `${authorId}@ex.com` } as ThreadEvent['author']),
|
||||
data: { assignees },
|
||||
has_unread_mention: false,
|
||||
is_editable: false,
|
||||
created_at: '2026-01-01T10:00:00Z',
|
||||
updated_at: '2026-01-01T10:00:00Z',
|
||||
});
|
||||
|
||||
describe('buildAssignmentMessage', () => {
|
||||
it('A — self assigned themself', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, SELF_ID, [{ id: SELF_ID, name: 'Me' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('You assigned yourself');
|
||||
});
|
||||
|
||||
it('B — self assigned others + themself', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, SELF_ID, [
|
||||
{ id: SELF_ID, name: 'Me' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('You assigned Bob and yourself');
|
||||
});
|
||||
|
||||
it('C — self assigned others only', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, SELF_ID, [{ id: 'bob', name: 'Bob' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('You assigned Bob');
|
||||
});
|
||||
|
||||
it('D — other assigned the current user', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [{ id: SELF_ID, name: 'Me' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned you');
|
||||
});
|
||||
|
||||
it('E — other assigned the current user + others', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [
|
||||
{ id: SELF_ID, name: 'Me' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned you and Bob');
|
||||
});
|
||||
|
||||
it('F — third party self-assigned (viewer not involved)', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [{ id: 'alice', name: 'Alice' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned themself');
|
||||
});
|
||||
|
||||
it('G — third party self-assigned + others (viewer not involved)', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [
|
||||
{ id: 'alice', name: 'Alice' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned themself and Bob');
|
||||
});
|
||||
|
||||
it('H — third party assigned viewer and themself', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [
|
||||
{ id: SELF_ID, name: 'Me' },
|
||||
{ id: 'alice', name: 'Alice' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned you and themself');
|
||||
});
|
||||
|
||||
it('I — third party assigned viewer, themself and others', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [
|
||||
{ id: SELF_ID, name: 'Me' },
|
||||
{ id: 'alice', name: 'Alice' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned you, themself and Bob');
|
||||
});
|
||||
|
||||
it('J — other assigned others (no self or author-self involved)', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice assigned Bob');
|
||||
});
|
||||
|
||||
it('third party self-unassigned', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.unassign, 'alice', [{ id: 'alice', name: 'Alice' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice unassigned themself');
|
||||
});
|
||||
|
||||
it('K — system unassigned the current user', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.unassign, null, [{ id: SELF_ID, name: 'Me' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('You were unassigned');
|
||||
});
|
||||
|
||||
it('L — system unassigned someone else', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.unassign, null, [{ id: 'bob', name: 'Bob' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('Bob was unassigned');
|
||||
});
|
||||
|
||||
it('self unassigned themself', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.unassign, SELF_ID, [{ id: SELF_ID, name: 'Me' }]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('You unassigned yourself');
|
||||
});
|
||||
|
||||
it('other unassigned the current user + others', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.unassign, 'alice', [
|
||||
{ id: SELF_ID, name: 'Me' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
]);
|
||||
expect(buildAssignmentMessage(event, SELF_ID, fakeT)).toBe('User alice unassigned you and Bob');
|
||||
});
|
||||
|
||||
it('falls back to 3rd-person wording when no currentUserId is provided', () => {
|
||||
const event = makeEvent(ThreadEventTypeEnum.assign, 'alice', [{ id: SELF_ID, name: 'Me' }]);
|
||||
expect(buildAssignmentMessage(event, undefined, fakeT)).toBe('User alice assigned Me');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ThreadEvent as ThreadEventType, ThreadEventTypeEnum, ThreadEventAssigneesData } from "@/features/api/gen/models";
|
||||
|
||||
type Assignee = { id: string; name: string };
|
||||
|
||||
export type AssignmentEvent = ThreadEventType & {
|
||||
type: 'assign' | 'unassign';
|
||||
data: ThreadEventAssigneesData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the localised sentence describing an ASSIGN/UNASSIGN event.
|
||||
*
|
||||
* Picks a wording variant based on who acts and who is impacted so the result
|
||||
* stays grammatically correct when the current user is involved — the previous
|
||||
* single-template approach produced sentences like "Vous a assigné Vous" once
|
||||
* self-substitution kicked in, and "User Un a assigné User Un" when a user
|
||||
* self-assigned.
|
||||
*
|
||||
* Cases (for each of assign/unassign):
|
||||
* A. author = me, assignees = [me] → "You assigned yourself"
|
||||
* B. author = me, assignees = [me, …others] → "You assigned X and yourself"
|
||||
* C. author = me, assignees = [others] → "You assigned X"
|
||||
* D. author ≠ me, assignees = [me] → "X assigned you"
|
||||
* E. author ≠ me, assignees = [me, …others (no author)] → "X assigned you and Y"
|
||||
* F. author ≠ me, in assignees alone → "X assigned themself"
|
||||
* G. author ≠ me, in assignees + others (no me) → "X assigned themself and Y"
|
||||
* H. author ≠ me, in assignees + me → "X assigned you and themself"
|
||||
* I. author ≠ me, in assignees + me + others → "X assigned you, themself and Y"
|
||||
* J. author ≠ me, no self / no author-self involved → "X assigned Y" (legacy key)
|
||||
* K. author = null (system), assignees = [me] → "You were unassigned"
|
||||
* L. author = null (system), no self involved → "X was unassigned" (legacy key)
|
||||
*/
|
||||
export const buildAssignmentMessage = (
|
||||
event: AssignmentEvent,
|
||||
currentUserId: string | undefined,
|
||||
t: TFunction,
|
||||
): string => {
|
||||
const isAssign = event.type === ThreadEventTypeEnum.assign;
|
||||
const assignees: Assignee[] = event.data.assignees ?? [];
|
||||
const authorId = event.author?.id;
|
||||
const authorName = event.author?.full_name || event.author?.email || t("Unknown");
|
||||
const isAuthorSelf = !!currentUserId && authorId === currentUserId;
|
||||
const isSystem = event.author === null;
|
||||
|
||||
const selfInAssignees = !!currentUserId && assignees.some((a) => a.id === currentUserId);
|
||||
// Author appears among assignees AND the viewer is not the author — that's
|
||||
// a "self-assign by a third party" from the viewer's perspective.
|
||||
const authorInAssignees = !isAuthorSelf && !!authorId && assignees.some((a) => a.id === authorId);
|
||||
|
||||
// "Others" excludes both the viewer (shown as "you") and the author when
|
||||
// they appear in assignees (shown as "themself").
|
||||
const others = assignees.filter((a) => a.id !== currentUserId && a.id !== authorId);
|
||||
const othersNames = others.map((a) => a.name).join(", ");
|
||||
const othersCount = others.length;
|
||||
|
||||
// System-emitted UNASSIGN (user lost edit rights, backend has no acting author).
|
||||
if (isSystem && !isAssign) {
|
||||
if (selfInAssignees) {
|
||||
return t("You were unassigned");
|
||||
}
|
||||
return t("{{assignees}} was unassigned", {
|
||||
assignees: assignees.map((a) => a.name).join(", "),
|
||||
count: assignees.length,
|
||||
});
|
||||
}
|
||||
|
||||
// A/B/C — acting user is the viewer
|
||||
if (isAuthorSelf) {
|
||||
if (selfInAssignees && othersCount === 0) {
|
||||
return isAssign ? t("You assigned yourself") : t("You unassigned yourself");
|
||||
}
|
||||
if (selfInAssignees) {
|
||||
return isAssign
|
||||
? t("You assigned {{assignees}} and yourself", { assignees: othersNames, count: othersCount })
|
||||
: t("You unassigned {{assignees}} and yourself", { assignees: othersNames, count: othersCount });
|
||||
}
|
||||
return isAssign
|
||||
? t("You assigned {{assignees}}", { assignees: othersNames, count: othersCount })
|
||||
: t("You unassigned {{assignees}}", { assignees: othersNames, count: othersCount });
|
||||
}
|
||||
|
||||
// Author is a third party from here on.
|
||||
|
||||
// H/I — viewer is in assignees AND author self-assigned
|
||||
if (selfInAssignees && authorInAssignees) {
|
||||
if (othersCount === 0) {
|
||||
return isAssign
|
||||
? t("{{author}} assigned you and themself", { author: authorName })
|
||||
: t("{{author}} unassigned you and themself", { author: authorName });
|
||||
}
|
||||
return isAssign
|
||||
? t("{{author}} assigned you, themself and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount })
|
||||
: t("{{author}} unassigned you, themself and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount });
|
||||
}
|
||||
|
||||
// D/E — viewer is in assignees, author did not self-assign
|
||||
if (selfInAssignees) {
|
||||
if (othersCount === 0) {
|
||||
return isAssign
|
||||
? t("{{author}} assigned you", { author: authorName })
|
||||
: t("{{author}} unassigned you", { author: authorName });
|
||||
}
|
||||
return isAssign
|
||||
? t("{{author}} assigned you and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount })
|
||||
: t("{{author}} unassigned you and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount });
|
||||
}
|
||||
|
||||
// F/G — author self-assigned (viewer not involved)
|
||||
if (authorInAssignees) {
|
||||
if (othersCount === 0) {
|
||||
return isAssign
|
||||
? t("{{author}} assigned themself", { author: authorName })
|
||||
: t("{{author}} unassigned themself", { author: authorName });
|
||||
}
|
||||
return isAssign
|
||||
? t("{{author}} assigned themself and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount })
|
||||
: t("{{author}} unassigned themself and {{assignees}}", { author: authorName, assignees: othersNames, count: othersCount });
|
||||
}
|
||||
|
||||
// J — nobody special, legacy 3rd-person wording
|
||||
const assigneesNames = assignees.map((a) => a.name).join(", ");
|
||||
return isAssign
|
||||
? t("{{author}} assigned {{assignees}}", { author: authorName, assignees: assigneesNames, count: assignees.length })
|
||||
: t("{{author}} unassigned {{assignees}}", { author: authorName, assignees: assigneesNames, count: assignees.length });
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { groupAssignmentEvents, computeAssignmentNetChange, RenderItem } from './index';
|
||||
import { ThreadEvent, ThreadEventTypeEnum, Message } from '@/features/api/gen/models';
|
||||
import { TimelineItem } from '@/features/providers/mailbox';
|
||||
|
||||
const makeAssignEvent = (
|
||||
id: string,
|
||||
authorId: string | null,
|
||||
assignees: { id: string; name: string }[],
|
||||
type: ThreadEventTypeEnum = ThreadEventTypeEnum.assign,
|
||||
createdAt = '2026-01-01T10:00:00Z',
|
||||
): ThreadEvent => ({
|
||||
id,
|
||||
thread: 'thread-1',
|
||||
type,
|
||||
channel: null,
|
||||
author: authorId
|
||||
? ({ id: authorId, full_name: `User ${authorId}`, email: `${authorId}@example.com` } as ThreadEvent['author'])
|
||||
: (null as unknown as ThreadEvent['author']),
|
||||
data: { assignees },
|
||||
has_unread_mention: false,
|
||||
is_editable: false,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
|
||||
const makeMessageItem = (id: string): TimelineItem => ({
|
||||
type: 'message',
|
||||
data: { id } as unknown as Message,
|
||||
created_at: '2026-01-01T10:00:30Z',
|
||||
});
|
||||
|
||||
const asEventItem = (event: ThreadEvent): TimelineItem => ({
|
||||
type: 'event',
|
||||
data: event,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
|
||||
describe('groupAssignmentEvents', () => {
|
||||
it('leaves messages untouched and wraps events individually when alone', () => {
|
||||
const assign = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const items: TimelineItem[] = [makeMessageItem('m1'), asEventItem(assign)];
|
||||
|
||||
const result = groupAssignmentEvents(items);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].kind).toBe('message');
|
||||
expect(result[1]).toEqual<RenderItem>({
|
||||
kind: 'event',
|
||||
data: assign,
|
||||
created_at: assign.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
it('groups 2+ consecutive assign/unassign events by the same author', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.assign);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const items: TimelineItem[] = [asEventItem(e1), asEventItem(e2)];
|
||||
|
||||
const result = groupAssignmentEvents(items);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].kind).toBe('assignment_group');
|
||||
if (result[0].kind !== 'assignment_group') throw new Error('type-guard');
|
||||
expect(result[0].events).toEqual([e1, e2]);
|
||||
expect(result[0].created_at).toBe(e2.created_at);
|
||||
});
|
||||
|
||||
it('does not group events from different authors', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'charlie', [{ id: 'dave', name: 'Dave' }]);
|
||||
const items: TimelineItem[] = [asEventItem(e1), asEventItem(e2)];
|
||||
|
||||
const result = groupAssignmentEvents(items);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'event')).toBe(true);
|
||||
});
|
||||
|
||||
it('breaks grouping when an unrelated item (e.g. message) sits between events', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const items: TimelineItem[] = [asEventItem(e1), makeMessageItem('m1'), asEventItem(e2)];
|
||||
|
||||
const result = groupAssignmentEvents(items);
|
||||
|
||||
expect(result.map((r) => r.kind)).toEqual(['event', 'message', 'event']);
|
||||
});
|
||||
|
||||
it('groups 3+ events from the same author in a single bucket', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const e3 = makeAssignEvent('e3', 'alice', [{ id: 'charlie', name: 'Charlie' }]);
|
||||
const items: TimelineItem[] = [asEventItem(e1), asEventItem(e2), asEventItem(e3)];
|
||||
|
||||
const result = groupAssignmentEvents(items);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].kind !== 'assignment_group') throw new Error('type-guard');
|
||||
expect(result[0].events).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAssignmentNetChange', () => {
|
||||
it('returns a single add for a lone assign', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
expect(computeAssignmentNetChange([e1])).toEqual([
|
||||
{ id: 'bob', name: 'Bob', status: 'added' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('cancels out an assign followed by an unassign for the same user', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.assign);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
expect(computeAssignmentNetChange([e1, e2])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns mixed added/removed for distinct users', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.assign);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'carol', name: 'Carol' }], ThreadEventTypeEnum.unassign);
|
||||
const result = computeAssignmentNetChange([e1, e2]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({ id: 'bob', name: 'Bob', status: 'added' });
|
||||
expect(result).toContainEqual({ id: 'carol', name: 'Carol', status: 'removed' });
|
||||
});
|
||||
|
||||
it('keeps only the latest status when the same user is touched multiple times in the same direction', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.assign);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.assign);
|
||||
expect(computeAssignmentNetChange([e1, e2])).toEqual([
|
||||
{ id: 'bob', name: 'Bob', status: 'added' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { groupSystemEvents, RenderItem } from './index';
|
||||
import { ThreadEvent, ThreadEventTypeEnum, Message } from '@/features/api/gen/models';
|
||||
import { TimelineItem } from '@/features/providers/mailbox';
|
||||
|
||||
const makeAssignEvent = (
|
||||
id: string,
|
||||
authorId: string | null,
|
||||
assignees: { id: string; name: string }[],
|
||||
type: ThreadEventTypeEnum = ThreadEventTypeEnum.assign,
|
||||
createdAt = '2026-01-01T10:00:00Z',
|
||||
): ThreadEvent => ({
|
||||
id,
|
||||
thread: 'thread-1',
|
||||
type,
|
||||
channel: null,
|
||||
author: authorId
|
||||
? ({ id: authorId, full_name: `User ${authorId}`, email: `${authorId}@example.com` } as ThreadEvent['author'])
|
||||
: (null as unknown as ThreadEvent['author']),
|
||||
data: { assignees },
|
||||
has_unread_mention: false,
|
||||
is_editable: false,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
|
||||
const makeIMEvent = (id: string, authorId: string, createdAt = '2026-01-01T10:00:15Z'): ThreadEvent => ({
|
||||
id,
|
||||
thread: 'thread-1',
|
||||
type: ThreadEventTypeEnum.im,
|
||||
channel: null,
|
||||
author: { id: authorId, full_name: `User ${authorId}`, email: `${authorId}@example.com` } as ThreadEvent['author'],
|
||||
data: { content: 'hello', mentions: [] },
|
||||
has_unread_mention: false,
|
||||
is_editable: false,
|
||||
created_at: createdAt,
|
||||
updated_at: createdAt,
|
||||
});
|
||||
|
||||
const makeMessageItem = (id: string): TimelineItem => ({
|
||||
type: 'message',
|
||||
data: { id } as unknown as Message,
|
||||
created_at: '2026-01-01T10:00:30Z',
|
||||
});
|
||||
|
||||
const asEventItem = (event: ThreadEvent): TimelineItem => ({
|
||||
type: 'event',
|
||||
data: event,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
|
||||
describe('groupSystemEvents', () => {
|
||||
it('leaves messages untouched and wraps a lone event individually', () => {
|
||||
const assign = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const items: TimelineItem[] = [makeMessageItem('m1'), asEventItem(assign)];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].kind).toBe('message');
|
||||
expect(result[1]).toEqual<RenderItem>({
|
||||
kind: 'event',
|
||||
data: assign,
|
||||
created_at: assign.created_at,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a run of 2 non-IM events inline (below collapse threshold)', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const items: TimelineItem[] = [asEventItem(e1), asEventItem(e2)];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((r) => r.kind === 'event')).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses a run of 3 non-IM events regardless of author', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'charlie', [{ id: 'dave', name: 'Dave' }]);
|
||||
const e3 = makeAssignEvent('e3', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const items: TimelineItem[] = [asEventItem(e1), asEventItem(e2), asEventItem(e3)];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
if (result[0].kind !== 'collapsed_events') throw new Error('type-guard');
|
||||
expect(result[0].events).toEqual([e1, e2, e3]);
|
||||
expect(result[0].created_at).toBe(e3.created_at);
|
||||
});
|
||||
|
||||
it('breaks a long run when a message sits in the middle', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const e3 = makeAssignEvent('e3', 'alice', [{ id: 'charlie', name: 'Charlie' }]);
|
||||
const items: TimelineItem[] = [
|
||||
asEventItem(e1),
|
||||
asEventItem(e2),
|
||||
makeMessageItem('m1'),
|
||||
asEventItem(e3),
|
||||
];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result.map((r) => r.kind)).toEqual(['event', 'event', 'message', 'event']);
|
||||
});
|
||||
|
||||
it('breaks a long run when an IM sits in the middle', () => {
|
||||
const e1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const e2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const im = makeIMEvent('im1', 'alice');
|
||||
const e3 = makeAssignEvent('e3', 'alice', [{ id: 'charlie', name: 'Charlie' }]);
|
||||
const e4 = makeAssignEvent('e4', 'alice', [{ id: 'charlie', name: 'Charlie' }], ThreadEventTypeEnum.unassign);
|
||||
const items: TimelineItem[] = [
|
||||
asEventItem(e1),
|
||||
asEventItem(e2),
|
||||
asEventItem(im),
|
||||
asEventItem(e3),
|
||||
asEventItem(e4),
|
||||
];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result.map((r) => r.kind)).toEqual(['event', 'event', 'event', 'event', 'event']);
|
||||
});
|
||||
|
||||
it('collapses only the sub-run that reaches the threshold', () => {
|
||||
const a1 = makeAssignEvent('e1', 'alice', [{ id: 'bob', name: 'Bob' }]);
|
||||
const a2 = makeAssignEvent('e2', 'alice', [{ id: 'bob', name: 'Bob' }], ThreadEventTypeEnum.unassign);
|
||||
const im = makeIMEvent('im1', 'alice');
|
||||
const b1 = makeAssignEvent('e3', 'alice', [{ id: 'charlie', name: 'Charlie' }]);
|
||||
const b2 = makeAssignEvent('e4', 'alice', [{ id: 'charlie', name: 'Charlie' }], ThreadEventTypeEnum.unassign);
|
||||
const b3 = makeAssignEvent('e5', 'alice', [{ id: 'dave', name: 'Dave' }]);
|
||||
const items: TimelineItem[] = [
|
||||
asEventItem(a1),
|
||||
asEventItem(a2),
|
||||
asEventItem(im),
|
||||
asEventItem(b1),
|
||||
asEventItem(b2),
|
||||
asEventItem(b3),
|
||||
];
|
||||
|
||||
const result = groupSystemEvents(items);
|
||||
|
||||
expect(result.map((r) => r.kind)).toEqual([
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'collapsed_events',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { TextHelper } from "@/features/utils/text-helper";
|
||||
import { DateHelper } from "@/features/utils/date-helper";
|
||||
import { Message, ThreadEvent as ThreadEventType, ThreadEventTypeEnum, ThreadEventAssigneesData, ThreadEventIMData } from "@/features/api/gen/models";
|
||||
@@ -10,6 +10,7 @@ import { Badge } from "@/features/ui/components/badge";
|
||||
import { AVATAR_COLORS, Icon, IconSize, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
|
||||
import clsx from "clsx";
|
||||
import { buildAssignmentMessage } from "./assignment-message";
|
||||
|
||||
const TWO_MINUTES_MS = 2 * 60 * 1000;
|
||||
|
||||
@@ -30,50 +31,45 @@ const isIMEvent = (event: ThreadEventType): event is TypedThreadEvent<'im'> =>
|
||||
/**
|
||||
* Rendered timeline item used by the thread view.
|
||||
*
|
||||
* Adds an ``assignment_group`` variant on top of ``TimelineItem`` that wraps
|
||||
* consecutive ASSIGN/UNASSIGN events by the same author so the UI can collapse
|
||||
* them into a single summary line, mirroring — at a longer timescale — the
|
||||
* idea behind the backend "undo window".
|
||||
* Adds a ``collapsed_events`` variant on top of ``TimelineItem`` that hides a
|
||||
* run of 3+ consecutive non-IM events behind a single summary line with an
|
||||
* expand toggle. Progressive disclosure keeps metadata out of the way without
|
||||
* losing audit information.
|
||||
*/
|
||||
export type RenderItem =
|
||||
| { kind: 'message'; data: Message; created_at: string }
|
||||
| { kind: 'event'; data: ThreadEventType; created_at: string }
|
||||
| { kind: 'assignment_group'; events: ThreadEventType[]; created_at: string };
|
||||
| { kind: 'collapsed_events'; events: ThreadEventType[]; created_at: string };
|
||||
|
||||
export type AssignmentNetChange = { id: string; name: string; status: 'added' | 'removed' };
|
||||
const COLLAPSE_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Collapses runs of 2+ consecutive ASSIGN/UNASSIGN events from the same author
|
||||
* (no other item between them) into a single ``assignment_group`` render item.
|
||||
* Groups consecutive non-IM ThreadEvents into ``collapsed_events`` runs when
|
||||
* 3 or more pile up between messages or IMs. Shorter runs stay as individual
|
||||
* inline events so the timeline keeps a natural chronological flow.
|
||||
*
|
||||
* Solo events are left untouched so the existing single-event rendering still
|
||||
* kicks in. The backend undo window already absorbs most fast click-regrets;
|
||||
* this handles the "changed my mind several minutes later" case that ends up
|
||||
* producing multiple events the backend can no longer merge.
|
||||
* Any message or IM flushes the buffer, so runs never cross conversational
|
||||
* content.
|
||||
*/
|
||||
export const groupAssignmentEvents = (items: readonly TimelineItem[]): RenderItem[] => {
|
||||
export const groupSystemEvents = (items: readonly TimelineItem[]): RenderItem[] => {
|
||||
const result: RenderItem[] = [];
|
||||
let buffer: ThreadEventType[] = [];
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (buffer.length === 0) return;
|
||||
if (buffer.length === 1) {
|
||||
const single = buffer[0];
|
||||
result.push({ kind: 'event', data: single, created_at: single.created_at });
|
||||
} else {
|
||||
if (buffer.length >= COLLAPSE_THRESHOLD) {
|
||||
const last = buffer[buffer.length - 1];
|
||||
result.push({ kind: 'assignment_group', events: [...buffer], created_at: last.created_at });
|
||||
result.push({ kind: 'collapsed_events', events: [...buffer], created_at: last.created_at });
|
||||
} else {
|
||||
for (const event of buffer) {
|
||||
result.push({ kind: 'event', data: event, created_at: event.created_at });
|
||||
}
|
||||
}
|
||||
buffer = [];
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === 'event' && isAssignmentEvent(item.data)) {
|
||||
const currentAuthorId = item.data.author?.id ?? null;
|
||||
const bufferAuthorId = buffer[0]?.author?.id ?? null;
|
||||
if (buffer.length > 0 && currentAuthorId !== bufferAuthorId) {
|
||||
flushBuffer();
|
||||
}
|
||||
if (item.type === 'event' && !isIMEvent(item.data)) {
|
||||
buffer.push(item.data);
|
||||
continue;
|
||||
}
|
||||
@@ -88,31 +84,6 @@ export const groupAssignmentEvents = (items: readonly TimelineItem[]): RenderIte
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces a series of ASSIGN/UNASSIGN events into the net set of changes.
|
||||
*
|
||||
* A user assigned then unassigned (or vice versa) inside the same group ends
|
||||
* up cancelled out, mirroring the net user-visible effect of the run.
|
||||
*/
|
||||
export const computeAssignmentNetChange = (events: ThreadEventType[]): AssignmentNetChange[] => {
|
||||
const net = new Map<string, AssignmentNetChange | null>();
|
||||
for (const event of events) {
|
||||
const data = event.data as ThreadEventAssigneesData | null;
|
||||
const assignees = data?.assignees ?? [];
|
||||
const incoming: 'added' | 'removed' =
|
||||
event.type === ThreadEventTypeEnum.assign ? 'added' : 'removed';
|
||||
for (const assignee of assignees) {
|
||||
const existing = net.get(assignee.id);
|
||||
if (existing && existing.status !== incoming) {
|
||||
net.set(assignee.id, null);
|
||||
} else {
|
||||
net.set(assignee.id, { id: assignee.id, name: assignee.name, status: incoming });
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(net.values()).filter((v): v is AssignmentNetChange => v !== null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the avatar palette color for a given name.
|
||||
* Mirrors the hash logic used by UserAvatar from @gouvfr-lasuite/ui-kit.
|
||||
@@ -349,53 +320,11 @@ export const ThreadEvent = ({ event, isCondensed = false, onEdit, onDelete, ment
|
||||
);
|
||||
}
|
||||
|
||||
// Assignment events: system-style compact rendering
|
||||
// Assignment events: single discrete line, no icon, no background.
|
||||
// Aligned with the Front/Linear convention of keeping system metadata out
|
||||
// of the way of actual conversation.
|
||||
if (isAssignmentEvent(event)) {
|
||||
const isAssign = event.type === ThreadEventTypeEnum.assign;
|
||||
const assigneeData = event.data;
|
||||
const assigneeNames = assigneeData.assignees?.map((a) => a.name).join(", ") ?? "";
|
||||
const assigneeCount = assigneeData.assignees?.length ?? 0;
|
||||
// UNASSIGN events with a null author are system events emitted when a
|
||||
// user loses full edit rights on the thread. Drop the author prefix so
|
||||
// the timeline does not read "Unknown unassigned X". Staying generic
|
||||
// ("was unassigned") on purpose: any more specific wording belongs on
|
||||
// a dedicated ThreadEvent type, not on this branch.
|
||||
const isSystemUnassign = !isAssign && event.author === null;
|
||||
const authorName = event.author?.full_name || event.author?.email || t("Unknown");
|
||||
let message: string;
|
||||
if (isSystemUnassign) {
|
||||
message = t("{{assignees}} was unassigned", {
|
||||
assignees: assigneeNames,
|
||||
count: assigneeCount,
|
||||
});
|
||||
} else if (isAssign) {
|
||||
message = t("{{author}} assigned {{assignees}}", {
|
||||
author: authorName,
|
||||
assignees: assigneeNames,
|
||||
count: assigneeCount,
|
||||
});
|
||||
} else {
|
||||
message = t("{{author}} unassigned {{assignees}}", {
|
||||
author: authorName,
|
||||
assignees: assigneeNames,
|
||||
count: assigneeCount,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="thread-event thread-event--system">
|
||||
<Icon name={isAssign ? "person_add" : "person_remove"} type={IconType.OUTLINED} aria-hidden="true" />
|
||||
<span className="thread-event__system-text">{message}</span>
|
||||
<span className="thread-event__system-time">
|
||||
{t('{{date}} at {{time}}', {
|
||||
date: DateHelper.formatDate(event.created_at, i18n.resolvedLanguage, false),
|
||||
time: new Date(event.created_at).toLocaleString(i18n.resolvedLanguage, {
|
||||
minute: '2-digit',
|
||||
hour: '2-digit',
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return <SystemEventLine event={event} currentUserId={user?.id} />;
|
||||
}
|
||||
|
||||
// Fallback for other event types
|
||||
@@ -423,61 +352,100 @@ export const ThreadEvent = ({ event, isCondensed = false, onEdit, onDelete, ment
|
||||
);
|
||||
};
|
||||
|
||||
type GroupedAssignmentEventProps = {
|
||||
type SystemEventLineProps = {
|
||||
event: TypedThreadEvent<'assign' | 'unassign'>;
|
||||
currentUserId: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders an ASSIGN/UNASSIGN event as a single discrete text line.
|
||||
*
|
||||
* Phrasing is delegated to ``buildAssignmentMessage`` which picks the right
|
||||
* i18n key for each viewer-relative case (self assigning self, someone
|
||||
* assigning the viewer, …) so the sentence stays grammatically correct.
|
||||
* The timestamp always includes the time, prefixed with day/date outside of
|
||||
* today — users shouldn’t have to hover to know when something happened.
|
||||
*/
|
||||
const SystemEventLine = ({ event, currentUserId }: SystemEventLineProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const message = buildAssignmentMessage(event, currentUserId, t);
|
||||
const timeLabel = DateHelper.formatEventTimestamp(event.created_at, i18n.resolvedLanguage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="thread-event thread-event--system-line"
|
||||
data-event-id={event.id}
|
||||
>
|
||||
<span className="thread-event__system-text">{message}</span>
|
||||
<span className="thread-event__system-time">· {timeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CollapsedEventsGroupProps = {
|
||||
events: ThreadEventType[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a run of consecutive ASSIGN/UNASSIGN events by the same author as a
|
||||
* single system-style line. Shows the *net* change (assignees added and/or
|
||||
* removed across the whole run) so bouncing between assign states collapses to
|
||||
* its actual outcome. When the net cancels out entirely, falls back to a
|
||||
* neutral "adjusted assignments" wording that still records that something
|
||||
* happened without listing phantom users.
|
||||
* Renders a run of 3+ consecutive non-IM events behind a disclosure toggle.
|
||||
* Collapsed by default to keep audit metadata out of the reading flow; expands
|
||||
* to a chronological list of the same inline lines used outside of groups.
|
||||
*/
|
||||
export const GroupedAssignmentEvent = ({ events }: GroupedAssignmentEventProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const last = events[events.length - 1];
|
||||
const authorName = last.author?.full_name || last.author?.email || t("Unknown");
|
||||
const net = computeAssignmentNetChange(events);
|
||||
const isEmptyNet = net.length === 0;
|
||||
export const CollapsedEventsGroup = ({ events }: CollapsedEventsGroupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const message = isEmptyNet
|
||||
? t("{{author}} adjusted assignments", { author: authorName })
|
||||
: t("{{author}} modified assignments", { author: authorName });
|
||||
const bucket = useMemo(() => {
|
||||
const latest = events[events.length - 1];
|
||||
return DateHelper.bucketDate(latest.created_at);
|
||||
}, [events]);
|
||||
|
||||
const bucketLabel =
|
||||
bucket === 'today' ? t('Today')
|
||||
: bucket === 'this_week' ? t('This week')
|
||||
: t('Older');
|
||||
|
||||
const summary = t('{{count}} assignment changes', {
|
||||
count: events.length,
|
||||
defaultValue_one: '{{count}} assignment change',
|
||||
defaultValue_other: '{{count}} assignment changes',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="thread-event thread-event--system thread-event--assignment-group">
|
||||
<Icon name="manage_accounts" type={IconType.OUTLINED} aria-hidden="true" />
|
||||
<span className="thread-event__system-text">
|
||||
{message}
|
||||
{!isEmptyNet && (
|
||||
<span className="thread-event__assignment-changes">
|
||||
{net.map((change) => (
|
||||
<span
|
||||
key={change.id}
|
||||
className={clsx(
|
||||
"thread-event__assignment-change",
|
||||
change.status === 'added'
|
||||
? "thread-event__assignment-change--added"
|
||||
: "thread-event__assignment-change--removed",
|
||||
)}
|
||||
>
|
||||
{change.status === 'added' ? '+' : '−'} {change.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="thread-event__system-time">
|
||||
{t('{{date}} at {{time}}', {
|
||||
date: DateHelper.formatDate(last.created_at, i18n.resolvedLanguage, false),
|
||||
time: new Date(last.created_at).toLocaleString(i18n.resolvedLanguage, {
|
||||
minute: '2-digit',
|
||||
hour: '2-digit',
|
||||
}),
|
||||
})}
|
||||
</span>
|
||||
<div className="thread-event thread-event--collapsed">
|
||||
<button
|
||||
type="button"
|
||||
className="thread-event__collapsed-toggle"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Icon
|
||||
type={IconType.OUTLINED}
|
||||
size={IconSize.X_SMALL}
|
||||
name={expanded ? 'expand_more' : 'chevron_right'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="thread-event__collapsed-summary">
|
||||
{summary}
|
||||
</span>
|
||||
<span className="thread-event__collapsed-bucket">· {bucketLabel}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="thread-event__collapsed-list">
|
||||
{events.map((evt) =>
|
||||
isAssignmentEvent(evt) ? (
|
||||
<SystemEventLine
|
||||
key={evt.id}
|
||||
event={evt}
|
||||
currentUserId={user?.id}
|
||||
/>
|
||||
) : (
|
||||
<Fragment key={evt.id} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, groupAssignmentEvents, GroupedAssignmentEvent } 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"
|
||||
@@ -62,12 +62,11 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
|
||||
// Refs for thread events with unread mentions
|
||||
const mentionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||
const { markMentionsRead } = useMentionRead(thread.id);
|
||||
// Collapse runs of consecutive ASSIGN/UNASSIGN events from the same author
|
||||
// into a single synthetic "assignment_group" item. The backend undo window
|
||||
// already absorbs fast click-regrets within 2 minutes; this handles the
|
||||
// "changed my mind later" case that still ends up producing multiple
|
||||
// events worth surfacing as one net-change summary.
|
||||
const renderItems = useMemo(() => groupAssignmentEvents(threadItems), [threadItems]);
|
||||
// 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]);
|
||||
@@ -296,10 +295,10 @@ const ThreadViewComponent = ({ threadItems, mailboxId, thread, showTrashedMessag
|
||||
</Banner>
|
||||
)}
|
||||
{renderItems.map((item, index) => {
|
||||
if (item.kind === 'assignment_group') {
|
||||
if (item.kind === 'collapsed_events') {
|
||||
return (
|
||||
<GroupedAssignmentEvent
|
||||
key={`assignment-group-${item.events[0].id}`}
|
||||
<CollapsedEventsGroup
|
||||
key={`collapsed-events-${item.events[0].id}`}
|
||||
events={item.events}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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.<size> 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;
|
||||
}
|
||||
@@ -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 }) => (
|
||||
<span data-testid="avatar">{fullName}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<AssigneesAvatarGroup users={[]} maxAvatars={2} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("exposes the size via data-size so the overflow circle can mirror avatar dimensions", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AssigneesAvatarGroup
|
||||
users={makeUsers(3)}
|
||||
maxAvatars={2}
|
||||
overflowMode="replace-last"
|
||||
size="small"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AssigneesAvatarGroup users={makeUsers(3)} maxAvatars={3} />,
|
||||
);
|
||||
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(
|
||||
<AssigneesAvatarGroup users={makeUsers(5)} maxAvatars={3} />,
|
||||
);
|
||||
expect(html.match(/data-testid="avatar"/g)).toHaveLength(3);
|
||||
expect(html).toContain(
|
||||
'<span class="assignees-avatar-group__overflow" aria-hidden="true">+2</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replace-last overflow mode", () => {
|
||||
it("shows all avatars when within the cap", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AssigneesAvatarGroup
|
||||
users={makeUsers(2)}
|
||||
maxAvatars={2}
|
||||
overflowMode="replace-last"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AssigneesAvatarGroup
|
||||
users={makeUsers(5)}
|
||||
maxAvatars={2}
|
||||
overflowMode="replace-last"
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/data-testid="avatar"/g)).toHaveLength(1);
|
||||
expect(html).toContain(
|
||||
'<span class="assignees-avatar-group__overflow" aria-hidden="true">+4</span>',
|
||||
);
|
||||
});
|
||||
|
||||
it("still shows one avatar + counter when exactly (maxAvatars+1)", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AssigneesAvatarGroup
|
||||
users={makeUsers(3)}
|
||||
maxAvatars={2}
|
||||
overflowMode="replace-last"
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/data-testid="avatar"/g)).toHaveLength(1);
|
||||
expect(html).toContain("+2");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AssigneesAvatarGroupUser>;
|
||||
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 (
|
||||
<span className="assignees-avatar-group" data-size={size}>
|
||||
{visible.map((user) => (
|
||||
<UserAvatar key={user.id} fullName={user.name} size={size} />
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span className="assignees-avatar-group__overflow" aria-hidden="true">
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user