Deux canaux indépendants :
services/push.py, filtrée par les réglages du joueur.notifications, lues par GET /me/inbox. Jamais filtrées par les réglages.Contrat d'origine : back/PUSH.md.
| Variable | Défaut | Rôle |
|---|---|---|
FCM_CREDENTIALS_FILE |
/run/secrets/slidz/firebase.json |
compte de service Firebase ; fichier absent = aucun envoi (dev, tests) |
MATCH_REMINDER_HOURS |
3 | rappel d'une partie en différé, N h avant la fin |
Table devices : token (clé, 300 caractères au plus), user_id, platform (ios | android), updated_at.
| Route | Effet |
|---|---|
PUT /me/devices {token, platform} |
crée la ligne, ou la réattribue au joueur connecté (un téléphone = un compte) |
POST /me/devices/forget {token} |
supprime la ligne si elle est à moi (jeton dans le corps, pas dans l'URL) |
Côté app (app/lib/api/push.dart, classe Push) :
register : au lancement si l'autorisation est déjà donnée ; avec ask: true (demande système) à l'ouverture d'uneonTokenRefresh.unregister : à la déconnexion et avant la suppression du compte.delete_account supprime aussi toutes les lignes devices du joueur.code métier ──► push.notify(db, kind, user_ids, title, body, **data)
│ 1. assert kind ∈ KINDS
│ 2. retire ceux qui ont coupé ce type (users.push_off)
│ 3. si data.conversation_id : retire les membres avec muted = true
│ 4. body coupé à 180 caractères ; ajouté à session.info["push"]
▼
COMMIT ──► écouteur after_commit ──► 1 tâche asyncio : deliver_all(pushes), l'une après l'autre
ROLLBACK ─► écouteur after_rollback ─► file vidée (rien ne part)
▼
deliver(push) : nouvelle session, SELECT devices.token WHERE user_id IN (…)
jeton d'accès OAuth (JWT RS256 signé avec la clé du compte de service, gardé ~1 h)
POST fcm.googleapis.com/v1/projects/<projet>/messages:send, un appel par jeton
réponse ≥ 400 : status UNREGISTERED / INVALID_ARGUMENT ou HTTP 404 → jeton à oublier ; sinon log
DELETE devices WHERE token IN (oubliés)
Message FCM : notification {title, body}, data (chaînes : type, match_id, conversation_id, question_id,
pseudo), son par défaut, priorité Android high. data.type dit où mène l'appui : match, conversation,
question, profile, leaderboard.
KINDS = ("message", "match", "match_reminder", "question", "question_milestone", "follow", "month_end").
| Type | Déclencheur | Destinataires | Code |
|---|---|---|---|
message |
message texte ou question dans une conversation | membres sauf l'auteur et ceux qui l'ont bloqué ; filtre muted |
conversations.notify_message |
match |
partie créée | joueurs de la partie sauf le lanceur (membres moins ceux liés au lanceur par un blocage) ; filtre muted |
matches.create_match |
match |
partie terminée (deux modes) | tous les match_players ; filtre muted |
matches.finish |
match_reminder |
différé, ends_at dans moins de MATCH_REMINDER_HOURS ; passage chaque minute (sweep_loop) |
joueurs sans finished_at, une fois (reminded_at) ; filtre muted |
matches.remind |
question |
question d'un joueur qui sort de pending vers published ou rejected |
l'auteur | moderation.notify_author |
question_milestone |
question d'un joueur (pas @slidz) qui franchit 100, 1 000 ou 10 000 parties ; calculé par le rollup |
l'auteur | analytics.celebrate_milestones |
follow |
nouvel abonné (abonnement refusé, 403, s'il existe un blocage dans un sens ou l'autre) | le joueur suivi, au plus 1 par 24 h et par couple (clé Redis push:follow:<abonné>:<suivi>) |
users.follow |
month_end |
clôture de la saison (rollup) | chaque joueur classé au général mondial | badges.month_report |
Textes exacts : dans le code de chaque fonction. Le titre d'une conversation de groupe est son nom ; en privé, le
pseudo de l'autre.
month_end : une par joueur classé).follow (redis.over_limit, 1 par 24 h par couple).Code : back/app/services/push.py (notify, _after_commit, deliver, Firebase), back/app/routers/me.py (register_device, forget_device, update_push_settings, mute_conversation)
users.push_off : liste JSON des types coupés. GET et PUT /me/notifications/settings ({"types": {kind: bool}},conversation_members.muted : PUT /me/conversations/{id}/mute. Coupe message, match et match_reminder de cetteconversation_id).Table notifications : user_id, kind, title (160), subtitle (200), from_pseudo, color_hex, read,
created_at.
kind |
Écrit par | Push associée |
|---|---|---|
follow |
users.follow, à chaque nouvel abonnement |
follow (limitée à 1 / 24 h) |
group |
groups.join quand un membre m'ajoute (pas par code) |
aucune |
moderation |
question publiée ou refusée (moderation.notify_author) ; photo retirée (avatars.refuse) |
question si la question sortait de pending ; aucune pour la photo |
milestone |
palier de parties d'une question | question_milestone |
month |
bilan du mois | month_end |
GET /me/inbox : 50 dernières, plus récentes d'abord. POST /me/inbox/{id}/read : read = true (la sienneCode : back/app/routers/me.py (inbox, read_notification), back/app/routers/users.py (follow), back/app/routers/groups.py (join), back/app/services/moderation.py (notify_author), back/app/services/avatars.py (refuse), back/app/services/analytics.py (celebrate_milestones), back/app/services/badges.py (month_report)
month_end fait autant d'appels que d'appareils classés, sur un seulfollow sans limite : se désabonner et se réabonner en boucle crée une ligne de boîte à chaque fois (laover_limit non atomique : INCR puis EXPIRE en deux appels. Si le second échoue, la clé reste sansfollow de ce couple pour toujours.