Aller au contenu

Notifications

Notifications persistantes de l'admin, envoyées par notify() depuis un module, lues dans la cloche de la barre supérieure, avec copie par courriel en option.

core/notifications garde la trace de ce qui est arrivé à un utilisateur pendant qu'il ne regardait pas : un dossier partagé, une facture payée, un rendez-vous pris. Un module appelle notify(), le core stocke une ligne par destinataire, et la cloche de l'admin l'affiche.

Règle absolue : un module notifie par notify(), jamais par sa propre table de notifications. Un toast confirme une action que l'utilisateur vient de faire, une notification signale un événement qu'il retrouvera plus tard. Voir Toasts.

Import

import {
  notify,
  listNotifications, getUnreadState, markRead, markUnread, markAllRead,
  deleteNotification, purgeNotifications,
  NOTIFICATION_LIMITS, MAX_RECIPIENT_IDS, MAX_PAGE_SIZE, UNREAD_COUNT_CAP,
  READ_RETENTION_DAYS, MAX_RETENTION_DAYS,
} from '@fraza/core/notifications';

Module serveur : il importe la base. La cloche fait partie du core et n'a rien à importer : elle appelle les routes ci-dessous.

API

FonctionRetourRôle
notify(input, { client }?)Promise<{ success, data, error }>Notifie un ou plusieurs utilisateurs, avec copie par courriel en option.
listNotifications(userId, { before, after, limit }?)Promise<{ notifications, hasMore }>Une page des notifications d'un utilisateur, de la plus récente à la plus ancienne.
getUnreadState(userId)Promise<{ count, latestId }>Le nombre de non-lues, plafonné, et l'identifiant de la plus récente.
markRead(userId, id)Promise<boolean>Marque une notification comme lue. false si l'utilisateur n'en a pas sous cet identifiant.
markUnread(userId, id)Promise<boolean>Remet une notification en non lue. false si l'utilisateur n'en a pas sous cet identifiant.
markAllRead(userId)Promise<number>Marque toutes les non-lues comme lues.
deleteNotification(userId, id)Promise<boolean>Retire une notification pour de bon. false si l'utilisateur n'en a pas sous cet identifiant.
purgeNotifications({ signal }?)Promise<number>L'étape de purge de fraza:maintenance.

notify(input, options?)

import { notify } from '@fraza/core/notifications';

await notify({
  to: { permission: 'billing.view' },
  type: 'billing.invoice-paid',
  source: '@fraza-pro/module-billing',
  title: 'Facture F-0042 payée',
  body: 'Studio Exemple inc. a réglé 1 250,00 $.',
  href: '/admin/billing/invoices/42',
  dedupeKey: 'invoice:42:paid',
  email: true,
});
ChampRequisDescription
toouiExactement une cible : { userId }, { userIds } ou { permission }. exclude, une liste d'identifiants, retire des destinataires : en général l'auteur de l'action.
typeouiCe qui s'est passé, en minuscules : drive.share, billing.invoice-paid. 100 caractères au plus.
sourceouiLe paquet qui notifie.
titleouiUne ligne, affichée dans la cloche et en objet du courriel. 200 caractères au plus.
bodynonQuelques lignes de détail. 1 000 caractères au plus.
hrefnonUn chemin du site, ouvert au clic : /admin/.... Une URL complète ou //autre.site est refusée.
dedupeKeynonLa même clé envoyée deux fois au même utilisateur ne donne qu'une notification.
emailnontrue met aussi un courriel dans la file d'envoi pour chaque destinataire.

Les destinataires sont lus dans fraza_auth_users au moment de l'appel : un identifiant inconnu ne crée rien. Une permission se résout aussi à l'appel : un utilisateur qui la reçoit plus tard ne voit pas les notifications passées.

Le retour suit la convention de queueEmail : { success: true, data: { count, ids } }, ou { success: false, error } quand l'entrée est invalide ou que l'écriture échoue. count compte les notifications créées, sans celles qu'une dedupeKey a écartées.

Dans une transaction, { client } lie les notifications et leurs courriels à celle de l'appelant : ils n'existent que si elle est validée. Une erreur lève alors au lieu de retourner success: false, pour que la transaction soit annulée. Sans client, un appel avec email: true ouvre sa propre transaction : les notifications et les courriels existent ensemble ou pas du tout.

import { transaction } from '@fraza/core/database';

await transaction(async (client) => {
  const share = await createShare(client, folderId, userId);
  await notify({
    to: { userId },
    type: 'drive.share',
    source: '@fraza/module-drive',
    title: 'Un dossier a été partagé',
    href: `/admin/drive/${folderId}`,
    dedupeKey: `share:${share.id}`,
  }, { client });
});

Le courriel reprend le titre, le corps et un bouton vers href, résolu sur NEXT_PUBLIC_URL. Sa clé de dédoublonnage est notification:<id>.

listNotifications(userId, options?)

const { notifications, hasMore } = await listNotifications(userId, { limit: 20 });
const next = await listNotifications(userId, { before: notifications.at(-1).id });

before donne la page qui suit l'identifiant, after les notifications plus récentes que lui. Les deux sont des curseurs sur id, pas des décalages : une notification qui arrive pendant la lecture ne décale pas les pages. limit vaut 20 par défaut, MAX_PAGE_SIZE (50) au plus.

Chaque notification porte id (une chaîne), type, source, title, body, href, createdAt et readAt, null tant qu'elle n'est pas lue.

getUnreadState(userId)

Ce que la cloche relit. count s'arrête à UNREAD_COUNT_CAP (100) : la pastille affiche « 9+ » bien avant, et compter au-delà coûterait une lecture complète. latestId est l'identifiant de la plus récente, lue ou non, null sans notification.

markRead, markUnread, markAllRead et deleteNotification

Une notification relue garde sa première date de lecture. markUnread la remet en non lue, pour qui veut y revenir plus tard. deleteNotification la retire de la liste de son destinataire seulement : les autres gardent leur copie. Sa dedupeKey part avec elle, et le même événement notifié de nouveau réapparaît.

Chacune retourne false pour l'identifiant d'un autre utilisateur, comme pour un identifiant inexistant.

Routes

Déclarées par le core, auth: 'user'. Chacune lit l'utilisateur dans la session, jamais dans la requête.

MéthodeCheminRôle
GET/za/api/notifications?before=&after=&limit=Une page de notifications. before et after ne se combinent pas.
GET/za/api/notifications/unread{ count, latestId }, la route du polling.
POST/za/api/notifications/:id/readMarque une notification comme lue. 404 si elle n'appartient pas à l'utilisateur.
POST/za/api/notifications/:id/unreadLa remet en non lue. 404 dans le même cas.
DELETE/za/api/notifications/:idLa retire. 404 dans le même cas.
POST/za/api/notifications/read-allMarque tout comme lu. Retourne { marked }.
POST/za/api/notifications/devkitauth: 'admin', sous FRAZA_DEVKIT=true seulement, 404 sinon. Envoie au compte connecté un scénario de démonstration de la galerie de composants : simple, detailed, burst ou email. Le scénario se choisit dans une liste fixe, jamais en texte libre.

La cloche

Elle est posée par le core dans les deux coquilles de l'admin, sidebar et topbar, à gauche des emplacements des modules et du menu utilisateur. Une pastille compte les non-lues, jusqu'à « 9+ ».

  • Polling. Le compteur est relu toutes les 45 secondes, dès que l'onglet revient au premier plan, et quand un composant du core émet l'événement fraza:notifications:refresh sur window, comme les boutons du devkit. Un onglet caché ne relit rien. Une notification apparaît donc avec 45 secondes de retard au plus, sans connexion permanente : le mécanisme marche sur un hébergement serverless comme sur plusieurs instances.
  • Toast à l'arrivée. Quand latestId dépasse le dernier vu, jusqu'à trois nouvelles notifications s'annoncent chacune par un toast info, avec un bouton « Voir » quand elles ont un lien. Au-delà, un seul toast les résume. Ce qui attendait avant l'ouverture de la page n'est pas annoncé.
  • Panneau. Une fenêtre non modale qui porte une liste, et non un menu : chaque ligne a trois actions, et un item de menu ne peut pas contenir d'autres boutons. Un clic sur la notification la marque comme lue et ouvre son lien. Le point à gauche bascule lu et non lu, le X à droite la retire, sans confirmation : une notification ne vaut pas qu'on la confirme. Le point d'une notification lue et le X n'apparaissent qu'au survol ou au focus de la ligne, et restent visibles sur un écran tactile. « Tout marquer comme lu », en en-tête, vide la pastille, « Afficher plus » charge la page suivante.
  • Clavier. Tab passe d'une action à l'autre, ↑ et ↓ sautent d'une notification à la suivante, Début et Fin vont aux bouts, Échap rend le focus à la cloche. Une ligne retirée donne son focus à la suivante.
  • Affichage d'avance. Une action se voit tout de suite, avant la réponse du serveur. S'il la refuse, un toast d'erreur s'affiche et la liste comme le compteur sont relus.

Notes

La table. fraza_notifications est créée par la migration 012-create-notifications du core, donc par npx fraza-db migrate. npx fraza-db drop la laisse en place, comme les autres tables créées par une migration.

Pas de clé étrangère vers les utilisateurs. fraza-db drop supprime fraza_auth_users avec CASCADE, ce qui retirerait la contrainte pour de bon alors que le journal dit la migration passée. notify() n'insère donc que pour des utilisateurs qui existent, et la purge supprime les notifications d'un compte qui n'existe plus.

La purge. La tâche fraza:maintenance supprime chaque heure les notifications lues depuis plus de READ_RETENTION_DAYS jours (30) et toutes celles de plus de MAX_RETENTION_DAYS jours (180). Une notification est une donnée personnelle : on ne la garde pas sans raison. Voir Cron.

Pas de préférences par utilisateur. Un utilisateur ne choisit pas encore les notifications qu'il reçoit, ni leur copie par courriel. C'est le module qui décide de email: true.

Sur cette page