Collaboration

Référence technique du domaine Collaboration — commentaires, threads, mentions, notes internes, réactions, inbox et notifications.

Collaboration

Périmètre : commentaires sur entités (modèle Comment), fils (réponse de profondeur 1 via parentCommentId), mentions @user (stockées dans Comment.mentionedUserIds), notes internes (modèle InternalNote distinct). Le composant CommentThread est réutilisé sur les fiches métier (lease affair, facture, maintenance…). Source de cette page : docs/14-domain-collab.md.

Les routes API vivent sous /api/workspace/threads/* : POST /api/workspace/threads/comments, POST /api/workspace/threads/internal-notes, GET /api/workspace/threads/[entity]/[entityId]. Permissions collaboration:read et collaboration:write.
Cible — non encore implémenté. Plusieurs fonctionnalités décrites ici relèvent de la cible et n'existent pas dans le code : réactions emoji (pas de modèle CommentReaction ni de route), épinglage (pinnedAt), watch d'entité (EntityWatch), inbox (/workspace/inbox), suppression soft (deletedAt) et attachements. Le schéma actuel se limite à CommentThread, Comment et InternalNote. Les sections concernées sont signalées ci-dessous.

Vocabulaire & entités

TermeEntité / ChampDéfinitionStatut
FilCommentThreadPivot unique par entité (@@unique(organizationId, entity, entityId)).implémenté
CommentaireCommentMessage texte rattaché à un CommentThread (visible par tous les membres).implémenté
RéponseComment.parentCommentIdRéponse de profondeur 1 sous un commentaire racine (null = racine).implémenté
MentionComment.mentionedUserIdsTableau d'IDs (String[]) parsés à la création — pas de table dédiée.implémenté
Note interneInternalNoteModèle distinct de Comment, jamais exposé aux portails externes.implémenté
RéactionCommentReactionEmoji posé sur un commentaire.cible (modèle absent)
ÉpinglageComment.pinnedAtUn commentaire épinglé au sommet du fil.cible (champ absent)
WatchEntityWatchAbonnement aux nouveaux commentaires d'une entité.cible (modèle absent)
InboxVue agrégée /workspace/inbox.cible (écran/route absents)

Schéma Prisma détaillé dans docs/04-data-model.md §11. Les notes internes ne sont pas un flag isInternal sur Comment : ce sont deux modèles séparés (Comment public, InternalNote interne).

Écrans

ÉcranRoute / ComposantContenuStatut
Composant CommentThreadIntégré dans les fichesListe chronologique, composer, fil de réponses, onglet notes internes.implémenté
Inbox/workspace/inboxCommentaires me mentionnant + fils suivis.cible (non implémenté)

Composant CommentThread — détail

Liste

Chronologique. Sépare les commentaires (Comment) et les notes internes (InternalNote) renvoyés par GET /api/workspace/threads/[entity]/[entityId].

Composer

Saisie texte (max 5 000 caractères). Mention @... parsée à la soumission (alimente Comment.mentionedUserIds).

Actions par commentaire

Réponse (parentCommentId, profondeur 1). Cible : réactions emoji, édition, suppression soft, épinglage (non implémentés).

Modèle de données (points clés)

  • CommentThread porte la clé d'entité : entity (string, ex. LeaseAffair, Invoice, MaintenanceOperation) + entityId, unique par (organizationId, entity, entityId). C'est le pivot, pas Comment.
  • Comment est rattaché à un CommentThread via threadId ; champs : authorUserId, body (@db.Text), mentionedUserIds (String[]), parentCommentId (nullable).
  • Comment.parentCommentId nullable : NULL = racine, non NULL = réponse (profondeur 1, relation CommentReplies).
  • InternalNote est un modèle distinct rattaché au même CommentThread ; jamais renvoyé aux portails externes. Le champ isInternal y vaut toujours true (explicite, pour la lisibilité des requêtes).
Cible — non encore implémenté. Le schéma actuel ne comporte ni Comment.deletedAt (suppression soft), ni Comment.pinnedAt (épinglage), ni table d'attachements. Les attachements via FileLink restent une cible.

API

Permissions vérifiées directement dans chaque handler.

Routes implémentées

MéthodeRoutePermission
GET/api/workspace/threads/[entity]/[entityId]collaboration:read
POST/api/workspace/threads/commentscollaboration:write
POST/api/workspace/threads/internal-notescollaboration:write

Routes cible (non implémentées)

Cible — non encore implémenté. Aucune de ces routes n'existe : édition/suppression de commentaire, réactions, épinglage, inbox. Il n'y a pas de route /api/workspace/comments ni de permissions comment:* — les seules permissions du domaine sont collaboration:read et collaboration:write (une permission de modération dédiée reste à créer).
MéthodeRoute ciblePermission cible
PATCH / DELETE/api/workspace/threads/comments/[id]collaboration:write (+ modération à définir)
POST / DELETE/api/workspace/threads/comments/[id]/reactionscollaboration:write
POST/api/workspace/threads/comments/[id]/pinmodération à définir
GET/api/workspace/inbox(authentifié)
POST/api/workspace/threads/commentsAuth

Crée un commentaire sur le fil d'une entité (le CommentThread est créé à la volée si absent). Si le texte contient des mentions @userId, elles sont parsées et stockées dans Comment.mentionedUserIds, et les notifications correspondantes sont émises. Permission collaboration:write.

Corps (JSON)

entity
string required
Nom de l'entité cible (LeaseAffair, Invoice, MaintenanceOperation…).
entityId
string required
Identifiant de l'entité cible.
body
string required
Contenu du commentaire (1 à 5 000 caractères).
parentCommentId
string
Identifiant du commentaire racine si réponse (profondeur 1).

Requête

curl -s -X POST "$API/api/workspace/threads/comments" \
  -H "Content-Type: application/json" -H "Cookie: $SESSION" \
  -d '{"entity":"LeaseAffair","entityId":"aff_…","body":"@alice peux-tu valider ?"}'

Réponse

{
  "id": "cmt_…",
  "authorUserId": "usr_…",
  "authorName": "Bob Martin",
  "body": "@alice peux-tu valider ?",
  "mentionedUserIds": ["usr_alice"],
  "parentCommentId": null,
  "createdAt": "2026-06-16T10:00:00Z"
}
Pour créer une note interne, utiliser POST /api/workspace/threads/internal-notes (corps entity, entityId, body ; pas de parentCommentId). C'est un modèle distinct, ce n'est pas un flag sur le commentaire.

Workflows

Mention @email

Saisie @email dans le body
  → À la soumission : parseMentions() résout les emails en userIds de l'org
  → Comment.mentionedUserIds = [userId, …]  (pas de table de jointure)
  → Notification kind=MENTION (in-app + email selon préférences) aux mentionnés ≠ auteur

Réponse à un commentaire

User B répond à un commentaire racine de User A (B ≠ A)
  → Comment créé avec parentCommentId = comment racine de A
  → Notification kind=COMMENT_REPLY à A

Note interne (modèle distinct)

POST /api/workspace/threads/internal-notes { entity, entityId, body }
  → InternalNote créée sur le CommentThread de l'entité
  → Jamais renvoyée aux portails externes (modèle séparé de Comment)

Watch d'entité

Cible — non encore implémenté. Pas de modèle EntityWatch ni de geste « Suivre » dans le code. Les notifications partent aujourd'hui uniquement sur mention et réponse.
User clique "Suivre" sur une entité
  → INSERT EntityWatch (userId, entity, entityId)
  → Tout nouveau commentaire → notification in-app au watcher

Notifications

ÉvénementCanalDestinataireStatut
Mention dans un commentaire (kind=MENTION)In-app + emailUtilisateur mentionnéimplémenté
Réponse à mon commentaire (kind=COMMENT_REPLY)In-appAuteur du commentaire racineimplémenté
Nouveau commentaire sur entité suivieIn-appWatchers de l'entitécible (pas de watch)

Règles métier

  • Isolation notes internes : InternalNote est un modèle distinct, jamais renvoyé par les portails externes (séparation au niveau du modèle, pas un flag).
  • Mentions : parsées par e-mail à la soumission ; seuls les utilisateurs de l'organisation sont résolus ; l'auteur ne se notifie pas lui-même.
  • Réponses : profondeur 1 — une réponse cible un commentaire racine (parentCommentId: null requis côté parent).
Cible — non encore implémenté. Fenêtre d'édition (15 min), suppression soft (deletedAt + « Commentaire supprimé »), épinglage (1 max par fil), réactions emoji et attachements (FileLink) ne sont pas dans le code à ce stade.