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
| Fonction | Retour | Rô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,
});| Champ | Requis | Description |
|---|---|---|
to | oui | Exactement une cible : { userId }, { userIds } ou { permission }. exclude, une liste d'identifiants, retire des destinataires : en général l'auteur de l'action. |
type | oui | Ce qui s'est passé, en minuscules : drive.share, billing.invoice-paid. 100 caractères au plus. |
source | oui | Le paquet qui notifie. |
title | oui | Une ligne, affichée dans la cloche et en objet du courriel. 200 caractères au plus. |
body | non | Quelques lignes de détail. 1 000 caractères au plus. |
href | non | Un chemin du site, ouvert au clic : /admin/.... Une URL complète ou //autre.site est refusée. |
dedupeKey | non | La même clé envoyée deux fois au même utilisateur ne donne qu'une notification. |
email | non | true 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éthode | Chemin | Rô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/read | Marque une notification comme lue. 404 si elle n'appartient pas à l'utilisateur. |
POST | /za/api/notifications/:id/unread | La remet en non lue. 404 dans le même cas. |
DELETE | /za/api/notifications/:id | La retire. 404 dans le même cas. |
POST | /za/api/notifications/read-all | Marque tout comme lu. Retourne { marked }. |
POST | /za/api/notifications/devkit | auth: '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:refreshsurwindow, 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
latestIddépasse le dernier vu, jusqu'à trois nouvelles notifications s'annoncent chacune par un toastinfo, 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.
Tabpasse d'une action à l'autre, ↑ et ↓ sautent d'une notification à la suivante,DébutetFinvont aux bouts,Échaprend 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.