Les documents des membres

Mettre un fichier de la bibliothèque à disposition des membres sans le copier, choisir qui le reçoit (tous, des groupes, des personnes), distinguer ce qui concerne un membre de ce qu’il peut ouvrir, suivre ce que chacun a lu, et ne servir le fichier qu’à ceux qui peuvent l’ouvrir.

Précédemment. Le membre a son compte, son profil et son avatar, envoyé et vérifié par son propre circuit, hors de la bibliothèque.

Le club diffuse des documents à ses membres : le compte rendu de l’assemblée générale pour tous, une attestation pour une seule personne, un planning pour un groupe. Les membres les retrouvent sur la page « Documents », les lisent dans un panneau d’aperçu, et l’entrée « Documents » du menu signale ce qu’ils n’ont pas encore lu.

Les parcours du chapitre#

Deux cas d’utilisation traversent ce chapitre : la publication, côté club, et la lecture, côté membre. Chaque onglet en déroule les étapes relevées dans le code ; chaque étape mène à son explication plus bas.

Acteur : Administrateur

  1. 1

    Depuis le finder, il choisit un fichier et sa diffusion

    memberDocument.publishadmin

    • MemberDocument créée : audience, groups, mediaAssetId, publishedById, recipients, title
    • MemberDocumentGroup créée
    • MemberDocumentRecipient créée

    Lit : MediaAsset, MemberDocument, MemberGroup, User

    Le chemin dans le code

    Chaque ligne est un appel, suivi de l'endroit où le lire (à ouvrir dans l'éditeur, par exemple avec la recherche de fichier).

    memberDocument.publish packages/backend/src/modules/memberDocuments/router.ts:282
    • ctx.prisma.memberDocument.create packages/backend/src/modules/memberDocuments/router.ts:338MemberDocument crééeMemberDocumentGroup crééeMemberDocumentRecipient créée

Une publication, pas une copie 📄#

Un document des membres est d’abord un fichier de la bibliothèque, rangé dans le finder comme les autres. Le publier ne le copie pas : cela crée une ligne qui pointe vers son et décrit sa diffusion.

MemberDocument
PRISMA
prisma/schema.prisma
model MemberDocument {  /// Identifiant CUID.  id           String     @id @default(cuid())  /// Fichier publié (unique).  mediaAssetId String     @unique  /// Fichier (1–1, MediaAsset.memberDocument). Supprimer le fichier supprime  /// la publication (CASCADE).  mediaAsset   MediaAsset @relation(fields: [mediaAssetId], references: [id], onDelete: Cascade)  /// Titre affiché. À défaut : nom affiché du fichier, puis son nom d'origine.  title String?  /// Diffusion : à tous, ou ciblée (voir DocumentAudience).  audience DocumentAudience @default(ALL_MEMBERS)  /// Date de publication. Ordonne les listes (plus récent d'abord) et borne les  /// documents « non lus » d'un membre (voir User.memberSince).  publishedAt   DateTime @default(now())  /// Membre qui a publié. null si son compte a été supprimé.  publishedById String?  /// Éditeur (N–1, User.memberDocumentsPublished). Détaché à la suppression du  /// membre (SET NULL).  publishedBy   User?    @relation("MemberDocumentPublisher", fields: [publishedById], references: [id], onDelete: SetNull)  /// Membres désignés nommément (1–N, MemberDocumentRecipient.memberDocument).  /// Pris en compte en SPECIFIC uniquement.  recipients MemberDocumentRecipient[]  /// Groupes visés (1–N, MemberDocumentGroup.memberDocument). Pris en compte en  /// SPECIFIC uniquement.  groups     MemberDocumentGroup[]  /// Statuts de lecture par membre (1–N, DocumentReceipt.memberDocument).  receipts   DocumentReceipt[]  @@index([audience])}

Ce choix permet trois choses. Le fichier n’existe qu’une fois : le corriger dans le finder corrige le document des membres. Son nom, son format et son type viennent du , sans être recopiés. Et est unique : un même fichier ne peut pas être publié deux fois, avec deux diffusions contradictoires.

La publication vit aussi longtemps que le fichier : la relation est en onDelete: Cascade. Un fichier supprimé de la bibliothèque ne laisse pas derrière lui un document fantôme. À l’inverse, retirer la publication ne touche pas au fichier. Et un fichier publié aux membres ne peut pas être jeté sans retirer d’abord la publication : la corbeille le refuse, en nommant « documents des membres » parmi ce qui l’utilise.

Qui reçoit le document#

Un document a deux modes de diffusion :

  • ALL_MEMBERS : tous les membres, y compris ceux qui adhèrent après la publication ;
  • SPECIFIC : une diffusion ciblée, vers des groupes (), des personnes désignées nommément (), ou les deux.

Les destinataires ne sont jamais recopiés dans une liste au moment de la publication. La question « ce document concerne-t-il ce membre ? » est posée à chaque lecture, par une seule condition Prisma :

Ce qui concerne un membre
TS
packages/backend/src/modules/memberDocuments/visibleTo.ts
export function memberDocumentVisibleTo(userId: string) {  return {    OR: [      { audience: "ALL_MEMBERS" as const },      {        audience: "SPECIFIC" as const,        OR: [          { recipients: { some: { userId } } },          {            groups: { some: { group: { memberships: { some: { userId } } } } },          },        ],      },    ],  };}

Un groupe est ainsi une liste de diffusion vivante : un membre ajouté au groupe « Bureau » reçoit les documents déjà adressés au bureau, et un membre qui en sort ne les voit plus. Aucune tâche de synchronisation, puisqu’il n’y a rien à synchroniser.

Concerner, et pouvoir ouvrir#

Une seconde catégorie de documents s’ajoute : les dépôts des espaces collaboratifs (chapitre 5). Quand un membre dépose un fichier dans l’espace de son groupe, il est publié comme document de ce groupe. Or les droits sur les espaces descendent la hiérarchie des groupes (chapitre 4) : un membre du groupe parent, ou un administrateur, a accès à l’espace d’un sous-groupe sans en être membre.

La cloche collaborative du header compte ces dépôts avec l’héritage. La condition de visibilité, elle, ne regardait que l’appartenance directe. Résultat : la cloche signalait à un administrateur des dépôts qu’il ne pouvait ni ouvrir, ni marquer lus — elle ne se vidait jamais. D’où une seconde condition, qui étend la première :

memberDocumentReadableBy — concerner, plus l’héritage

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Les espaces accessibles, héritage compris
TS
packages/backend/src/modules/memberDocuments/visibleTo.ts
guided extract: lines 45-62focus line 5
import type { Prisma, PrismaClient } from "@prisma/client";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";/** * Condition Prisma « ce document des membres concerne cet utilisateur ». * * Partagée par le routeur (liste, compteurs, marquage lu / non lu) et par la route de * livraison du fichier (`/api/media/member-document/[id]`) : une seule règle, pour que ce * qu'un membre voit dans la liste et ce qu'il peut ouvrir ne divergent jamais. * *   - ALL_MEMBERS : tous les membres ; *   - SPECIFIC : destinataire désigné OU membre d'un groupe visé. L'appartenance est résolue *     à chaque lecture : un membre ajouté à un groupe reçoit les documents déjà adressés au *     groupe, un membre qui en sort ne les voit plus. */export function memberDocumentVisibleTo(userId: string) {  return {    OR: [      { audience: "ALL_MEMBERS" as const },      {        audience: "SPECIFIC" as const,        OR: [          { recipients: { some: { userId } } },          {            groups: { some: { group: { memberships: { some: { userId } } } } },          },        ],      },    ],  };}/** * Condition « ce membre peut OUVRIR ce document, et le marquer lu ou non lu ». * * `memberDocumentVisibleTo`, PLUS les documents déposés dans un espace collaboratif auquel il a * accès par HÉRITAGE (membre d'un groupe parent, administrateur). La cloche collaborative compte * ces documents (`collaborativeEntriesForMember`) : sans cette condition, elle signalait des * documents non lus que le membre ne pouvait ni ouvrir, ni marquer lus — la cloche ne se vidait * jamais, pour tout administrateur en particulier. * * Partagée par la liste, le marquage et la route de livraison du fichier. */export async function memberDocumentReadableBy(  prisma: PrismaClient,  userId: string,): Promise<{ where: Prisma.MemberDocumentWhereInput; collaborativeGroupIds: string[] }> {  const collaborativeGroupIds = (await collaborativeEntriesForMember(prisma, userId)).map((e) => e.groupId);  const visible = memberDocumentVisibleTo(userId);  return {    where:      collaborativeGroupIds.length === 0        ? visible        : {            OR: [              ...visible.OR,              { audience: "SPECIFIC" as const, groups: { some: { groupId: { in: collaborativeGroupIds } } } },            ],          },    collaborativeGroupIds,  };}

Les deux conditions ont chacune leur emploi :

Condition

Question

Utilisée par

memberDocumentVisibleTo
Ce document lui est-il adressé ?
Le badge « Documents » du menu (ses non-lus).
memberDocumentReadableBy
Peut-il l’ouvrir ?
La liste de la page « Documents », le marquage lu / non lu, la route qui livre le fichier.

Le badge « Documents » reste sur la première : un administrateur ne veut pas voir chaque dépôt de chaque groupe compté parmi les documents que le club lui adresse ; ces dépôts ont leur cloche. Mais tout ce que la cloche signale doit pouvoir être ouvert. Un clic dans la cloche ouvre désormais le document lui-même, ce qui le marque lu :

La cloche ouvre le document
TSX
apps/web/src/features/app-shell/CollaborativeBell.tsx
empty code block

Ce qu’un compteur signale, l’utilisateur doit pouvoir le traiter. Un compteur et l’action qui le fait baisser doivent lire la même règle d’accès — ici la même liste de groupes, calculée par la même fonction.

Publier depuis le finder#

Les administrateurs publient un fichier sans quitter la bibliothèque, par trois chemins : le menu contextuel d’un fichier (dans les quatre vues du finder), l’option « Rendre les PDF disponibles aux membres » du formulaire d’envoi, et le glisser-déposer dans ce même formulaire. Tous ouvrent la même boîte de dialogue, qui choisit la diffusion, et appellent la même procédure :

memberDocument.publish

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Réservé aux administrateurs
TS
packages/backend/src/modules/memberDocuments/router.ts
guided extract: lines 282-350focus line 1
import { isDemoEmail } from "@backend/lib/demoAccounts";import { z } from "zod";import { TRPCError } from "@trpc/server";import { router, protectedProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { assertCanWriteGroupSpace } from "@backend/modules/memberGroups/assertCanWriteGroupSpace.service";import { groupIdFromLogicalPath } from "@backend/modules/memberGroups/assertCanTrashPaths.service";import { memberDocumentReadableBy, memberDocumentVisibleTo } from "@backend/modules/memberDocuments/visibleTo";/** * Documents membres : fichiers du finder mis à disposition des membres, avec * statut de lecture par membre. * * Modèle : `MemberDocument` (surcouche sur un MediaAsset) ; audience * ALL_MEMBERS (tous, nouveaux inclus d'office) ou SPECIFIC (destinataires * explicites = perso aujourd'hui, bureau demain) ; `DocumentReceipt` = lu/non * lu par membre (absence de ligne ou readAt null = non lu). */const adminProcedure = protectedProcedure.use(isAdmin);/** Condition « ce document concerne l'utilisateur » (partagée avec la route de livraison). */const visibleToUser = memberDocumentVisibleTo;export const memberDocumentRouter = router({  // ─── Membre ────────────────────────────────────────────────────────────  /** Documents visibles par le membre courant, avec son statut de lecture. */  listForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    // Lisibles : ce qui le concerne, plus les dépôts des espaces collaboratifs hérités.    const readable = await memberDocumentReadableBy(ctx.prisma, userId);    const myGroupIds = new Set([      ...(        await ctx.prisma.memberGroupMembership.findMany({          where: { userId },          select: { groupId: true },        })      ).map((m) => m.groupId),      ...readable.collaborativeGroupIds,    ]);    const docs = await ctx.prisma.memberDocument.findMany({      where: readable.where,      orderBy: { publishedAt: "desc" },      select: {        id: true,        title: true,        audience: true,        publishedAt: true,        mediaAsset: {          select: {            displayName: true,            originalFileName: true,            mimeType: true,            format: true,          },        },        receipts: { where: { userId }, select: { readAt: true } },        recipients: { where: { userId }, select: { userId: true } },        groups: { select: { group: { select: { id: true, name: true } } } },      },    });    return docs.map((d) => ({      id: d.id,      title:        d.title ?? d.mediaAsset.displayName ?? d.mediaAsset.originalFileName,      audience: d.audience,      publishedAt: d.publishedAt,      mimeType: d.mediaAsset.mimeType,      format: d.mediaAsset.format,      // Pas de reçu, ou reçu à readAt null (re-marqué non lu) = non lu.      readAt: d.receipts[0]?.readAt ?? null,      // Destinataire direct (document personnel) ?      personal: d.recipients.length > 0,      // Groupes visés dont ce membre fait partie (par quel groupe il l'a reçu).      groups: d.groups.map((g) => g.group).filter((g) => myGroupIds.has(g.id)),    }));  }),  /** Nombre de documents non lus (pour la cloche). */  unreadCountForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const me = await ctx.prisma.user.findUnique({      where: { id: userId },      select: { memberSince: true },    });    // Borne = 1er janvier de l'ANNÉE d'adhésion (pas la date exacte) : un membre    // qui adhère après la dernière AG doit quand même être invité à lire le    // compte rendu de la même année. Les documents antérieurs restent listés    // sur la page (lecture volontaire). memberSince null → pas de borne.    const yearStart = me?.memberSince      ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1))      : null;    return ctx.prisma.memberDocument.count({      where: {        ...visibleToUser(userId),        // Non lu = aucun reçu daté pour ce membre.        receipts: { none: { userId, readAt: { not: null } } },        ...(yearStart ? { publishedAt: { gte: yearStart } } : {}),      },    });  }),  /**   * Compteur de la CLOCHE collaborative : documents non lus ciblant les   * groupes collaboratifs accessibles au membre (héritage compris), hors   * ses propres dépôts. Distinct de « Documents » (perso/diffusion).   */  collaborativeUnreadCountForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const entries = await collaborativeEntriesForMember(ctx.prisma, userId);    const groupIds = entries.map((e) => e.groupId);    if (groupIds.length === 0) return 0;    return ctx.prisma.memberDocument.count({      where: {        groups: { some: { groupId: { in: groupIds } } },        receipts: { none: { userId, readAt: { not: null } } },        NOT: { publishedById: userId },      },    });  }),  /**   * LISTE des documents non lus des espaces collaboratifs accessibles   * (héritage compris), hors ses propres dépôts — pour le menu de la cloche.   */  collaborativeUnreadForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const entries = await collaborativeEntriesForMember(ctx.prisma, userId);    const groupIds = entries.map((e) => e.groupId);    if (groupIds.length === 0) return [];    const docs = await ctx.prisma.memberDocument.findMany({      where: {        groups: { some: { groupId: { in: groupIds } } },        receipts: { none: { userId, readAt: { not: null } } },        NOT: { publishedById: userId },      },      select: {        id: true,        title: true,        mediaAsset: { select: { originalFileName: true } },        groups: { select: { group: { select: { id: true, name: true } } } },      },      orderBy: { publishedAt: "desc" },      take: 20,    });    return docs.map((d) => ({      id: d.id,      title: d.title ?? d.mediaAsset?.originalFileName ?? "Document",      groupName:        d.groups.find((g) => groupIds.includes(g.group.id))?.group.name ??        d.groups[0]?.group.name ??        "",    }));  }),  /** Non-lus ventilés : généraux (ALL_MEMBERS, bornés à l'année d'adhésion) et   *  persos (SPECIFIC pour ce membre, toujours signalés). Pour le badge de   *  l'item « Documents » de la navbar. */  unreadBreakdownForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const me = await ctx.prisma.user.findUnique({      where: { id: userId },      select: { memberSince: true },    });    const yearStart = me?.memberSince      ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1))      : null;    const unread = { receipts: { none: { userId, readAt: { not: null } } } };    const generalBound = yearStart ? { publishedAt: { gte: yearStart } } : {};    const myGroups = await ctx.prisma.memberGroupMembership.findMany({      where: { userId },      select: { group: { select: { id: true, name: true } } },    });    const [general, perso, total, ...groupCounts] = await Promise.all([      ctx.prisma.memberDocument.count({        where: { audience: "ALL_MEMBERS" as const, ...unread, ...generalBound },      }),      ctx.prisma.memberDocument.count({        where: {          audience: "SPECIFIC" as const,          recipients: { some: { userId } },          ...unread,        },      }),      // Total DISTINCT (un doc atteignant le membre par plusieurs canaux compté      // une seule fois) : sert au badge.      ctx.prisma.memberDocument.count({        where: {          ...unread,          OR: [            { audience: "ALL_MEMBERS" as const, ...generalBound },            {              audience: "SPECIFIC" as const,              OR: [                { recipients: { some: { userId } } },                {                  groups: {                    some: { group: { memberships: { some: { userId } } } },                  },                },              ],            },          ],        },      }),      ...myGroups.map(({ group }) =>        ctx.prisma.memberDocument.count({          where: {            audience: "SPECIFIC" as const,            groups: { some: { groupId: group.id } },            ...unread,          },        }),      ),    ]);    const byGroup = myGroups      .map(({ group }, i) => ({        groupId: group.id,        name: group.name,        count: groupCounts[i] ?? 0,      }))      .filter((g) => g.count > 0);    return { general, perso, total, byGroup };  }),  /** Marquer lu (à l'ouverture de l'aperçu). */  markRead: protectedProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const readable = await memberDocumentReadableBy(ctx.prisma, userId);      const visible = await ctx.prisma.memberDocument.findFirst({        where: { AND: [{ id: input.id }, readable.where] },        select: { id: true },      });      if (!visible) {        throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." });      }      await ctx.prisma.documentReceipt.upsert({        where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } },        create: { memberDocumentId: input.id, userId, readAt: new Date() },        update: { readAt: new Date() },      });      return { success: true };    }),  /** Re-marquer non lu (garde le signal de la cloche pour y revenir). */  markUnread: protectedProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const readable = await memberDocumentReadableBy(ctx.prisma, userId);      const visible = await ctx.prisma.memberDocument.findFirst({        where: { AND: [{ id: input.id }, readable.where] },        select: { id: true },      });      if (!visible) {        throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." });      }      await ctx.prisma.documentReceipt.upsert({        where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } },        create: { memberDocumentId: input.id, userId, readAt: null },        update: { readAt: null },      });      return { success: true };    }),  // ─── Admin ─────────────────────────────────────────────────────────────  /** Met un fichier du finder à disposition des membres. */  publish: adminProcedure    .input(      z.object({        path: z.string(),        title: z.string().trim().max(300).optional(),        audience: z.enum(["ALL_MEMBERS", "SPECIFIC"]),        recipientUserIds: z.array(z.string()).optional(),        groupIds: z.array(z.string()).optional(),      }),    )    .mutation(async ({ ctx, input }) => {      const recipientIds =        input.audience === "SPECIFIC" ? (input.recipientUserIds ?? []) : [];      const groupIds =        input.audience === "SPECIFIC" ? (input.groupIds ?? []) : [];      if (        input.audience === "SPECIFIC" &&        recipientIds.length === 0 &&        groupIds.length === 0      ) {        throw new TRPCError({          code: "BAD_REQUEST",          message: "Choisissez au moins un groupe ou un membre.",        });      }      // Le finder fournit un chemin, pas un id : la clé de stockage (`fullPath`, unique).      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) {        throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." });      }      const existing = await ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: { id: true },      });      if (existing) {        throw new TRPCError({          code: "CONFLICT",          message: "Ce fichier est déjà mis à disposition des membres.",        });      }      // Destinataires et groupes vérifiés en base : un identifiant inconnu (un membre supprimé      // pendant que la boîte de dialogue était ouverte) donne un message, pas une erreur interne.      const [knownUsers, knownGroups] = await Promise.all([        ctx.prisma.user.count({ where: { id: { in: recipientIds } } }),        ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }),      ]);      if (knownUsers !== new Set(recipientIds).size || knownGroups !== new Set(groupIds).size) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Destinataire ou groupe inconnu : rechargez la page." });      }      const doc = await ctx.prisma.memberDocument.create({        data: {          mediaAssetId: asset.id,          title: input.title,          audience: input.audience,          publishedById: ctx.sessionClient.user.id,          recipients: { create: [...new Set(recipientIds)].map((id) => ({ userId: id })) },          groups: { create: [...new Set(groupIds)].map((id) => ({ groupId: id })) },        },        select: { id: true },      });      return { id: doc.id };    }),  /**   * Dépôt d'un fichier dans l'espace d'un groupe collaboratif par un   * ÉDITEUR (ou admin) : publie le MediaAsset comme MemberDocument ciblé   * sur CE groupe, pour que tout le système documents (reçus, badges,   * cloche) s'applique. Idempotent doux (ne re-publie pas un asset déjà   * mis à disposition).   */  depositToGroupSpace: protectedProcedure    .input(      z.object({        path: z.string(),        groupId: z.string(),        title: z.string().trim().max(300).optional(),      }),    )    .mutation(async ({ ctx, input }) => {      await assertCanWriteGroupSpace({        prisma: ctx.prisma,        userId: ctx.sessionClient.user.id,        groupId: input.groupId,      });      // Le fichier doit se trouver DANS l'espace de ce groupe : être éditeur d'un groupe ne      // permet pas d'y diffuser un fichier pris ailleurs (document personnel d'un autre      // membre, espace d'un autre groupe…), que ses membres pourraient alors ouvrir.      if (input.path.includes("..") || groupIdFromLogicalPath(input.path) !== input.groupId) {        throw new TRPCError({          code: "FORBIDDEN",          message: "Ce fichier n'est pas dans l'espace de ce groupe.",        });      }      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) {        throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." });      }      const existing = await ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: { id: true },      });      if (existing) {        return { id: existing.id, alreadyPublished: true };      }      const doc = await ctx.prisma.memberDocument.create({        data: {          mediaAssetId: asset.id,          title: input.title,          audience: "SPECIFIC",          publishedById: ctx.sessionClient.user.id,          groups: { create: [{ groupId: input.groupId }] },        },        select: { id: true },      });      return { id: doc.id, alreadyPublished: false };    }),  /** Retire la mise à disposition (cascade reçus + destinataires). */  unpublish: adminProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      await ctx.prisma.memberDocument.delete({ where: { id: input.id } });      return { success: true };    }),  /** Liste admin de tous les documents mis à disposition. */  listAdmin: adminProcedure.query(async ({ ctx }) => {    return ctx.prisma.memberDocument.findMany({      orderBy: { publishedAt: "desc" },      select: {        id: true,        title: true,        audience: true,        publishedAt: true,        mediaAsset: { select: { displayName: true, originalFileName: true } },        _count: { select: { recipients: true, receipts: true } },      },    });  }),  /** Membres, pour choisir les destinataires d'une diffusion restreinte. */  listMembers: adminProcedure.query(async ({ ctx }) => {    const users = await ctx.prisma.user.findMany({      orderBy: [{ lastName: "asc" }, { firstName: "asc" }, { email: "asc" }],      select: { id: true, firstName: true, lastName: true, email: true },    });    return users.map((u) => ({      id: u.id,      name:        [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email,      email: u.email,      isDemo: isDemoEmail(u.email),    }));  }),  /** État de mise à disposition d'un fichier (pour le menu du finder). */  publicationForPath: adminProcedure    .input(z.object({ path: z.string() }))    .query(async ({ ctx, input }) => {      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) return null;      return ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: {          id: true,          audience: true,          _count: { select: { recipients: true } },        },      });    }),});

Lu, non lu#

Le suivi de lecture repose sur , une ligne par membre et par document :

État

En base

Signalé au membre ?

Jamais ouvert
pas de ligne
oui
Ouvert
readAt = date d’ouverture
non
Re-marqué non lu
readAt = null
oui

Aucune ligne n’est créée à la publication : un document adressé à deux cents membres n’écrit rien de plus en base. La ligne naît à la première ouverture de l’aperçu :

memberDocument.markRead

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Pouvoir l’ouvrir
TS
packages/backend/src/modules/memberDocuments/router.ts
guided extract: lines 238-256focus line 7
import { isDemoEmail } from "@backend/lib/demoAccounts";import { z } from "zod";import { TRPCError } from "@trpc/server";import { router, protectedProcedure } from "@backend/trpc/core";import { isAdmin } from "@backend/trpc/middleware";import { collaborativeEntriesForMember } from "@backend/modules/memberGroups/collaborativeEntriesForMember.service";import { assertCanWriteGroupSpace } from "@backend/modules/memberGroups/assertCanWriteGroupSpace.service";import { groupIdFromLogicalPath } from "@backend/modules/memberGroups/assertCanTrashPaths.service";import { memberDocumentReadableBy, memberDocumentVisibleTo } from "@backend/modules/memberDocuments/visibleTo";/** * Documents membres : fichiers du finder mis à disposition des membres, avec * statut de lecture par membre. * * Modèle : `MemberDocument` (surcouche sur un MediaAsset) ; audience * ALL_MEMBERS (tous, nouveaux inclus d'office) ou SPECIFIC (destinataires * explicites = perso aujourd'hui, bureau demain) ; `DocumentReceipt` = lu/non * lu par membre (absence de ligne ou readAt null = non lu). */const adminProcedure = protectedProcedure.use(isAdmin);/** Condition « ce document concerne l'utilisateur » (partagée avec la route de livraison). */const visibleToUser = memberDocumentVisibleTo;export const memberDocumentRouter = router({  // ─── Membre ────────────────────────────────────────────────────────────  /** Documents visibles par le membre courant, avec son statut de lecture. */  listForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    // Lisibles : ce qui le concerne, plus les dépôts des espaces collaboratifs hérités.    const readable = await memberDocumentReadableBy(ctx.prisma, userId);    const myGroupIds = new Set([      ...(        await ctx.prisma.memberGroupMembership.findMany({          where: { userId },          select: { groupId: true },        })      ).map((m) => m.groupId),      ...readable.collaborativeGroupIds,    ]);    const docs = await ctx.prisma.memberDocument.findMany({      where: readable.where,      orderBy: { publishedAt: "desc" },      select: {        id: true,        title: true,        audience: true,        publishedAt: true,        mediaAsset: {          select: {            displayName: true,            originalFileName: true,            mimeType: true,            format: true,          },        },        receipts: { where: { userId }, select: { readAt: true } },        recipients: { where: { userId }, select: { userId: true } },        groups: { select: { group: { select: { id: true, name: true } } } },      },    });    return docs.map((d) => ({      id: d.id,      title:        d.title ?? d.mediaAsset.displayName ?? d.mediaAsset.originalFileName,      audience: d.audience,      publishedAt: d.publishedAt,      mimeType: d.mediaAsset.mimeType,      format: d.mediaAsset.format,      // Pas de reçu, ou reçu à readAt null (re-marqué non lu) = non lu.      readAt: d.receipts[0]?.readAt ?? null,      // Destinataire direct (document personnel) ?      personal: d.recipients.length > 0,      // Groupes visés dont ce membre fait partie (par quel groupe il l'a reçu).      groups: d.groups.map((g) => g.group).filter((g) => myGroupIds.has(g.id)),    }));  }),  /** Nombre de documents non lus (pour la cloche). */  unreadCountForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const me = await ctx.prisma.user.findUnique({      where: { id: userId },      select: { memberSince: true },    });    // Borne = 1er janvier de l'ANNÉE d'adhésion (pas la date exacte) : un membre    // qui adhère après la dernière AG doit quand même être invité à lire le    // compte rendu de la même année. Les documents antérieurs restent listés    // sur la page (lecture volontaire). memberSince null → pas de borne.    const yearStart = me?.memberSince      ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1))      : null;    return ctx.prisma.memberDocument.count({      where: {        ...visibleToUser(userId),        // Non lu = aucun reçu daté pour ce membre.        receipts: { none: { userId, readAt: { not: null } } },        ...(yearStart ? { publishedAt: { gte: yearStart } } : {}),      },    });  }),  /**   * Compteur de la CLOCHE collaborative : documents non lus ciblant les   * groupes collaboratifs accessibles au membre (héritage compris), hors   * ses propres dépôts. Distinct de « Documents » (perso/diffusion).   */  collaborativeUnreadCountForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const entries = await collaborativeEntriesForMember(ctx.prisma, userId);    const groupIds = entries.map((e) => e.groupId);    if (groupIds.length === 0) return 0;    return ctx.prisma.memberDocument.count({      where: {        groups: { some: { groupId: { in: groupIds } } },        receipts: { none: { userId, readAt: { not: null } } },        NOT: { publishedById: userId },      },    });  }),  /**   * LISTE des documents non lus des espaces collaboratifs accessibles   * (héritage compris), hors ses propres dépôts — pour le menu de la cloche.   */  collaborativeUnreadForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const entries = await collaborativeEntriesForMember(ctx.prisma, userId);    const groupIds = entries.map((e) => e.groupId);    if (groupIds.length === 0) return [];    const docs = await ctx.prisma.memberDocument.findMany({      where: {        groups: { some: { groupId: { in: groupIds } } },        receipts: { none: { userId, readAt: { not: null } } },        NOT: { publishedById: userId },      },      select: {        id: true,        title: true,        mediaAsset: { select: { originalFileName: true } },        groups: { select: { group: { select: { id: true, name: true } } } },      },      orderBy: { publishedAt: "desc" },      take: 20,    });    return docs.map((d) => ({      id: d.id,      title: d.title ?? d.mediaAsset?.originalFileName ?? "Document",      groupName:        d.groups.find((g) => groupIds.includes(g.group.id))?.group.name ??        d.groups[0]?.group.name ??        "",    }));  }),  /** Non-lus ventilés : généraux (ALL_MEMBERS, bornés à l'année d'adhésion) et   *  persos (SPECIFIC pour ce membre, toujours signalés). Pour le badge de   *  l'item « Documents » de la navbar. */  unreadBreakdownForMe: protectedProcedure.query(async ({ ctx }) => {    const userId = ctx.sessionClient.user.id;    const me = await ctx.prisma.user.findUnique({      where: { id: userId },      select: { memberSince: true },    });    const yearStart = me?.memberSince      ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1))      : null;    const unread = { receipts: { none: { userId, readAt: { not: null } } } };    const generalBound = yearStart ? { publishedAt: { gte: yearStart } } : {};    const myGroups = await ctx.prisma.memberGroupMembership.findMany({      where: { userId },      select: { group: { select: { id: true, name: true } } },    });    const [general, perso, total, ...groupCounts] = await Promise.all([      ctx.prisma.memberDocument.count({        where: { audience: "ALL_MEMBERS" as const, ...unread, ...generalBound },      }),      ctx.prisma.memberDocument.count({        where: {          audience: "SPECIFIC" as const,          recipients: { some: { userId } },          ...unread,        },      }),      // Total DISTINCT (un doc atteignant le membre par plusieurs canaux compté      // une seule fois) : sert au badge.      ctx.prisma.memberDocument.count({        where: {          ...unread,          OR: [            { audience: "ALL_MEMBERS" as const, ...generalBound },            {              audience: "SPECIFIC" as const,              OR: [                { recipients: { some: { userId } } },                {                  groups: {                    some: { group: { memberships: { some: { userId } } } },                  },                },              ],            },          ],        },      }),      ...myGroups.map(({ group }) =>        ctx.prisma.memberDocument.count({          where: {            audience: "SPECIFIC" as const,            groups: { some: { groupId: group.id } },            ...unread,          },        }),      ),    ]);    const byGroup = myGroups      .map(({ group }, i) => ({        groupId: group.id,        name: group.name,        count: groupCounts[i] ?? 0,      }))      .filter((g) => g.count > 0);    return { general, perso, total, byGroup };  }),  /** Marquer lu (à l'ouverture de l'aperçu). */  markRead: protectedProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const readable = await memberDocumentReadableBy(ctx.prisma, userId);      const visible = await ctx.prisma.memberDocument.findFirst({        where: { AND: [{ id: input.id }, readable.where] },        select: { id: true },      });      if (!visible) {        throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." });      }      await ctx.prisma.documentReceipt.upsert({        where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } },        create: { memberDocumentId: input.id, userId, readAt: new Date() },        update: { readAt: new Date() },      });      return { success: true };    }),  /** Re-marquer non lu (garde le signal de la cloche pour y revenir). */  markUnread: protectedProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      const userId = ctx.sessionClient.user.id;      const readable = await memberDocumentReadableBy(ctx.prisma, userId);      const visible = await ctx.prisma.memberDocument.findFirst({        where: { AND: [{ id: input.id }, readable.where] },        select: { id: true },      });      if (!visible) {        throw new TRPCError({ code: "NOT_FOUND", message: "Document introuvable." });      }      await ctx.prisma.documentReceipt.upsert({        where: { memberDocumentId_userId: { memberDocumentId: input.id, userId } },        create: { memberDocumentId: input.id, userId, readAt: null },        update: { readAt: null },      });      return { success: true };    }),  // ─── Admin ─────────────────────────────────────────────────────────────  /** Met un fichier du finder à disposition des membres. */  publish: adminProcedure    .input(      z.object({        path: z.string(),        title: z.string().trim().max(300).optional(),        audience: z.enum(["ALL_MEMBERS", "SPECIFIC"]),        recipientUserIds: z.array(z.string()).optional(),        groupIds: z.array(z.string()).optional(),      }),    )    .mutation(async ({ ctx, input }) => {      const recipientIds =        input.audience === "SPECIFIC" ? (input.recipientUserIds ?? []) : [];      const groupIds =        input.audience === "SPECIFIC" ? (input.groupIds ?? []) : [];      if (        input.audience === "SPECIFIC" &&        recipientIds.length === 0 &&        groupIds.length === 0      ) {        throw new TRPCError({          code: "BAD_REQUEST",          message: "Choisissez au moins un groupe ou un membre.",        });      }      // Le finder fournit un chemin, pas un id : la clé de stockage (`fullPath`, unique).      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) {        throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." });      }      const existing = await ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: { id: true },      });      if (existing) {        throw new TRPCError({          code: "CONFLICT",          message: "Ce fichier est déjà mis à disposition des membres.",        });      }      // Destinataires et groupes vérifiés en base : un identifiant inconnu (un membre supprimé      // pendant que la boîte de dialogue était ouverte) donne un message, pas une erreur interne.      const [knownUsers, knownGroups] = await Promise.all([        ctx.prisma.user.count({ where: { id: { in: recipientIds } } }),        ctx.prisma.memberGroup.count({ where: { id: { in: groupIds } } }),      ]);      if (knownUsers !== new Set(recipientIds).size || knownGroups !== new Set(groupIds).size) {        throw new TRPCError({ code: "BAD_REQUEST", message: "Destinataire ou groupe inconnu : rechargez la page." });      }      const doc = await ctx.prisma.memberDocument.create({        data: {          mediaAssetId: asset.id,          title: input.title,          audience: input.audience,          publishedById: ctx.sessionClient.user.id,          recipients: { create: [...new Set(recipientIds)].map((id) => ({ userId: id })) },          groups: { create: [...new Set(groupIds)].map((id) => ({ groupId: id })) },        },        select: { id: true },      });      return { id: doc.id };    }),  /**   * Dépôt d'un fichier dans l'espace d'un groupe collaboratif par un   * ÉDITEUR (ou admin) : publie le MediaAsset comme MemberDocument ciblé   * sur CE groupe, pour que tout le système documents (reçus, badges,   * cloche) s'applique. Idempotent doux (ne re-publie pas un asset déjà   * mis à disposition).   */  depositToGroupSpace: protectedProcedure    .input(      z.object({        path: z.string(),        groupId: z.string(),        title: z.string().trim().max(300).optional(),      }),    )    .mutation(async ({ ctx, input }) => {      await assertCanWriteGroupSpace({        prisma: ctx.prisma,        userId: ctx.sessionClient.user.id,        groupId: input.groupId,      });      // Le fichier doit se trouver DANS l'espace de ce groupe : être éditeur d'un groupe ne      // permet pas d'y diffuser un fichier pris ailleurs (document personnel d'un autre      // membre, espace d'un autre groupe…), que ses membres pourraient alors ouvrir.      if (input.path.includes("..") || groupIdFromLogicalPath(input.path) !== input.groupId) {        throw new TRPCError({          code: "FORBIDDEN",          message: "Ce fichier n'est pas dans l'espace de ce groupe.",        });      }      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) {        throw new TRPCError({ code: "NOT_FOUND", message: "Fichier introuvable." });      }      const existing = await ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: { id: true },      });      if (existing) {        return { id: existing.id, alreadyPublished: true };      }      const doc = await ctx.prisma.memberDocument.create({        data: {          mediaAssetId: asset.id,          title: input.title,          audience: "SPECIFIC",          publishedById: ctx.sessionClient.user.id,          groups: { create: [{ groupId: input.groupId }] },        },        select: { id: true },      });      return { id: doc.id, alreadyPublished: false };    }),  /** Retire la mise à disposition (cascade reçus + destinataires). */  unpublish: adminProcedure    .input(z.object({ id: z.string() }))    .mutation(async ({ ctx, input }) => {      await ctx.prisma.memberDocument.delete({ where: { id: input.id } });      return { success: true };    }),  /** Liste admin de tous les documents mis à disposition. */  listAdmin: adminProcedure.query(async ({ ctx }) => {    return ctx.prisma.memberDocument.findMany({      orderBy: { publishedAt: "desc" },      select: {        id: true,        title: true,        audience: true,        publishedAt: true,        mediaAsset: { select: { displayName: true, originalFileName: true } },        _count: { select: { recipients: true, receipts: true } },      },    });  }),  /** Membres, pour choisir les destinataires d'une diffusion restreinte. */  listMembers: adminProcedure.query(async ({ ctx }) => {    const users = await ctx.prisma.user.findMany({      orderBy: [{ lastName: "asc" }, { firstName: "asc" }, { email: "asc" }],      select: { id: true, firstName: true, lastName: true, email: true },    });    return users.map((u) => ({      id: u.id,      name:        [u.firstName, u.lastName].filter(Boolean).join(" ").trim() || u.email,      email: u.email,      isDemo: isDemoEmail(u.email),    }));  }),  /** État de mise à disposition d'un fichier (pour le menu du finder). */  publicationForPath: adminProcedure    .input(z.object({ path: z.string() }))    .query(async ({ ctx, input }) => {      const asset = await ctx.prisma.mediaAsset.findUnique({        where: { fullPath: input.path },        select: { id: true },      });      if (!asset) return null;      return ctx.prisma.memberDocument.findUnique({        where: { mediaAssetId: asset.id },        select: {          id: true,          audience: true,          _count: { select: { recipients: true } },        },      });    }),});

« Non lu » se compte comme « aucun reçu daté pour ce membre » (receipts: { none: { userId, readAt: { not: null } } }), ce qui couvre à la fois l’absence de ligne et la ligne remise à null. Le membre peut re-marquer un document comme non lu, pour garder le signal et y revenir plus tard.

Ce qui est signalé, et à partir de quand#

Le badge de l’entrée « Documents » ne signale pas tout l’historique. Un membre qui adhère en octobre ne doit pas trouver vingt documents « non lus » datant d’avant son arrivée :

Une borne : le début de l’année d’adhésion
TS
packages/backend/src/modules/memberDocuments/router.ts
    const yearStart = me?.memberSince      ? new Date(Date.UTC(me.memberSince.getUTCFullYear(), 0, 1))      : null;    return ctx.prisma.memberDocument.count({      where: {        ...visibleToUser(userId),        // Non lu = aucun reçu daté pour ce membre.        receipts: { none: { userId, readAt: { not: null } } },        ...(yearStart ? { publishedAt: { gte: yearStart } } : {}),

Le début de l’année, et non la date exacte d’adhésion : un membre qui arrive après l’assemblée générale est quand même invité à lire son compte rendu. Les documents plus anciens restent dans la liste de la page ; les lire est une démarche volontaire, pas une alerte. Les documents personnels et ceux des groupes sont toujours signalés : ils ont été adressés à ce membre en particulier.

Le badge affiche un total distinct : un document qui atteint le membre par deux chemins (à titre personnel et par son groupe) ne compte qu’une fois. L’infobulle détaille le nombre par provenance : le club, chaque groupe, les documents personnels.

Servir le fichier à ceux qui peuvent l’ouvrir#

Un document des membres n’a pas d’URL publique, et aucune URL signée du stockage n’est donnée au navigateur. Il est servi par une route du site :

GET /api/media/member-document/[id]

Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.

Une session
TS
apps/web/src/app/api/media/member-document/[id]/route.ts
guided extract: lines 34-65focus lines 6-7
// ⚠ Fichier introuvable : apps/web/src/app/api/media/member-document/[id]/route.ts// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.

Le fichier est ensuite lu dans le stockage et transmis par la route elle-même, sous la même origine que le site :

Les en-têtes de la réponse
TS
apps/web/src/app/api/media/member-document/[id]/route.ts
empty code block

Deux raisons à ce choix plutôt qu’une redirection vers une URL signée. L’aperçu des PDF (pdf.js) télécharge le fichier en JavaScript, ce qu’une redirection vers un autre domaine bloquerait (CORS). Et chaque accès repasse par la vérification : il n’existe aucun lien qu’un membre pourrait transmettre à quelqu’un d’autre. private, no-store interdit à tout cache intermédiaire de le garder ; ?download=1 sert le même fichier en pièce jointe, sous son nom lisible.

Seuls les types sans danger s’affichent dans le navigateur : PDF, images (hors SVG), texte, audio et vidéo. Tout autre type est servi en pièce jointe. nosniff interdit au navigateur de deviner un autre type que celui annoncé, et une politique sandbox empêche toute exécution de script — sauf pour un PDF, dont le lecteur intégré des navigateurs refuse de s’afficher dans un document « sandboxé ».

Récapitulatif#

Question

Réponse du projet

Publier copie-t-il le fichier ?
Non : MemberDocument pointe vers le MediaAsset, une fois au plus ; la publication disparaît avec le fichier.
Qui reçoit un document ?
Tous (ALL_MEMBERS), ou des groupes et des personnes (SPECIFIC) ; l’appartenance aux groupes est résolue à chaque lecture.
Qu’est-ce qui le concerne, et que peut-il ouvrir ?
memberDocumentVisibleTo pour son badge ; memberDocumentReadableBy, qui ajoute les dépôts des espaces hérités, pour la liste, le marquage et le fichier.
Comment le site sait-il ce qui a été lu ?
Une ligne DocumentReceipt créée à la première ouverture ; pas de ligne ou readAt null = non lu.
Pourquoi un nouveau membre n’a-t-il pas tout l’historique en non lu ?
Les documents pour tous ne sont signalés qu’à partir du 1er janvier de son année d’adhésion.
Comment le fichier est-il protégé ?
Une route du site vérifie la session et le droit d’ouvrir à chaque accès, répond 404 sinon, et transmet le fichier elle-même, sans URL signée.

À suivre#

Les documents sont adressés à des groupes, et les groupes ouvrent des espaces. Avant d’y entrer, il faut comprendre ce qu’est un groupe ici : une liste de diffusion, un niveau d’accès, et une place dans un arbre dont la racine est « Administrateurs ».

Chapitre suivant : les groupes. Deux sortes de groupes, lecteur ou éditeur, une hiérarchie qui transmet ses droits vers le bas, une racine qui ne bouge pas, et un arbre sans cycle.

Sur cette page