fixup! (global) allow thread assignation

This commit is contained in:
jbpenrath
2026-04-23 14:09:48 +02:00
parent 267372520d
commit b04b07a465
70 changed files with 2681 additions and 1380 deletions

View File

@@ -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

View File

@@ -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": {

View File

@@ -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())

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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")
)

View File

@@ -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):

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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)]

View File

@@ -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

View File

@@ -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}"
)

View File

@@ -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"}]},
)

View File

@@ -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()

View File

@@ -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.

View File

@@ -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."

View File

@@ -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."

View File

@@ -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";

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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[];
}

View File

@@ -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[];

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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],
);

View File

@@ -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]);

View File

@@ -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];

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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>
);
};

View File

@@ -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 {

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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"

View File

@@ -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

View File

@@ -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>
);
};

View File

@@ -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}
/>
</>
);
});

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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 });
};

View File

@@ -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' },
]);
});
});

View File

@@ -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',
]);
});
});

View File

@@ -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 shouldnt 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>
);
};

View File

@@ -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}
/>
);

View File

@@ -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;
}

View File

@@ -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");
});
});
});

View File

@@ -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>
);
};

View File

@@ -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.

View File

@@ -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";