Aller au contenu

Envoyer un push

Le transport push de Kaiho est un tuyau, pas une plateforme d’engagement : pas de console, pas de séquences automatiques, pas de segments enregistrés. Une campagne est un fichier de texte, relu à la main, envoyé depuis un terminal. Le contexte et les raisons de ce choix sont dans Notifications ; cette page-ci est le mode d’emploi.

Trois gestes, toujours dans cet ordre. L’essai à blanc est le mode par défaut : il faut un drapeau explicite pour en sortir.

  1. Écrire la campagne. Copier scripts/campaigns/example.json (dans kaiho-backend) et réécrire la copy.
  2. Essai à blanc. Il résout la vraie audience et affiche, langue par langue, le texte exact que ces téléphones liraient — et lesquels retombent sur la traduction par défaut. Rien ne part.
  3. Envoyer. Même commande, drapeau explicite.
Fenêtre de terminal
node scripts/send-push.mjs campagnes/ma-campagne.json # essai à blanc
node scripts/send-push.mjs campagnes/ma-campagne.json --send # envoi réel

Tout ce que le serveur sait d’un envoi tient dans ce fichier. Il n’y a rien de stocké ailleurs : ni planning, ni segment, ni historique.

{
"campaign": "reprise-septembre",
"audience": { "user_ids": ["f91b8209-…", "8db0f8fb-…"] },
"message": {
"deep_link": "coach",
"default": { "title": "…", "body": "…" },
"by_locale": { "fr": { "title": "…", "body": "…" } }
}
}
Champ Ce qu’il fait
campaign Slug stable. Il fait trois choses : propriété PostHog pour l’entonnoir, clé de regroupement (un renvoi remplace la bannière au lieu d’en empiler une seconde), et étiquette dans le compte-rendu. Un slug par campagne, jamais réutilisé pour un autre message.
audience { "all": true } ou { "user_ids": [...] }. Voir ci-dessous.
deep_link coach, tracking, achievements ou profile. Rien d’autre n’existe. Absent ou inconnu : l’app s’ouvre là où elle était.
default Obligatoire. Ce que lit un appareil dont la langue n’est pas prévue.
by_locale Clé = sous-tag principal. pt-BR et pt-PT tombent tous les deux sur pt.

L’outil ne connaît pas de segments. Il attend une liste d’identifiants, produite en SQL. C’est volontaire : la sélection reste lisible et vérifiable avant l’envoi.

Forme Quand
"all": true Tous les appareils joignables. À réserver aux annonces qui concernent vraiment tout le monde.
"user_ids": [...] Quelques dizaines d’identifiants, écrits dans le fichier.
--users-from ids.txt Un identifiant par ligne. La bonne forme dès qu’un SQL produit la liste.

Toutes les requêtes ci-dessous ne renvoient que des gens réellement joignables : la jointure sur user_push_tokens avec notifications_authorized écarte d’office ceux qui n’ont pas l’app à jour ou qui ont coupé les notifications.

-- Non-premium, compte de plus de 7 jours
select distinct t.user_id
from public.user_push_tokens t
join public.users u on u.id = t.user_id
left join public.user_entitlements e on e.user_id = t.user_id
where t.notifications_authorized
and coalesce(e.is_premium, false) = false
and u.created_at < now() - interval '7 days';
-- Sans date d'arrêt — la plus grosse population
select distinct t.user_id
from public.user_push_tokens t
join public.users u on u.id = t.user_id
where t.notifications_authorized
and u.quit_date is null
and u.planned_quit_date is null;
-- Arrêtés depuis exactement 30 jours — un cap à célébrer
select distinct t.user_id
from public.user_push_tokens t
join public.users u on u.id = t.user_id
where t.notifications_authorized
and u.quit_date::date = (now() - interval '30 days')::date;
-- N'ont pas ouvert l'app depuis 30 jours — le trou que le push comble
select distinct t.user_id
from public.user_push_tokens t
join auth.users a on a.id = t.user_id
where t.notifications_authorized
and a.last_sign_in_at < now() - interval '30 days';
-- Par fuseau — indispensable pour viser une heure locale
select distinct user_id
from public.user_push_tokens
where notifications_authorized
and timezone = 'Europe/Paris';
Fenêtre de terminal
# Exporter le résultat en CSV depuis l'éditeur SQL Supabase, puis :
tail -n +2 export.csv | tr -d '"' > ids.txt
node scripts/send-push.mjs ma-campagne.json --users-from ids.txt

Une seule commande suffit jusqu’à plusieurs milliers d’appareils. La fonction envoie 32 requêtes en vol, pagine l’audience par 1 000 et découpe les listes d’identifiants par 300 pour rester sous la taille d’URL de PostgREST — tout cela est interne, il n’y a rien à régler.

Ce qui concerne l’expéditeur, c’est le temps d’exécution : une edge function n’a pas l’éternité, et un envoi qui expire à mi-parcours laisse une moitié d’audience servie sans moyen simple de savoir laquelle. Au-delà d’environ 20 000 appareils, découper.

Fenêtre de terminal
split -l 5000 ids.txt lot-
for f in lot-*; do
echo "→ $f"
node scripts/send-push.mjs ma-campagne.json --users-from "$f" --send
sleep 5
done

Garder le même slug d’un lot à l’autre : les destinataires sont disjoints, donc aucun regroupement ne s’applique, et PostHog voit une seule campagne au lieu de quatre.

{
"campaign": "reprise-septembre",
"audience_users": 1840,
"tokens": 1902,
"sent": 1889,
"failed": 13,
"pruned": 13,
"by_platform": { "ios": 812, "android": 1090 },
"errors": [ { "reason": "ios 410 Unregistered", "count": 13 } ]
}
Champ Lecture
audience_users Personnes. tokens est plus grand dès que quelqu’un a deux appareils.
sent Accepté par Apple ou Google. Pas « affiché ».
pruned Jetons morts supprimés (désinstallation, jeton tourné). Entretien normal, pas un incident.
failed sans pruned Échecs non terminaux — réseau, transporteur. Les jetons sont gardés, le prochain envoi retentera.

Les ouvertures se mesurent en entonnoir PostHog, rapproché par la propriété campaign : push_sent (émis par le serveur, un par destinataire, dédupliqué) puis notification_opened avec category = push. Les envois depuis dev n’y apparaissent pas : la clé PostHog est volontairement absente des secrets de dev, parce que les deux environnements partagent le même projet et que des tests y feraient du bruit.

Pas de planning. L’envoi part au moment où la commande est lancée. Ni programmation, ni file d’attente. Pour envoyer à 19 h, il faut être là à 19 h — ou passer par un at / un cron côté machine.

Pas de fuseaux. Le fuseau de chaque appareil est connu (il est dans la table) mais la fonction ne s’en sert pas pour étaler l’envoi : tout part en même temps. Pour viser une heure locale, une requête par fuseau et une commande par fuseau. Sur une base majoritairement française, une seule vague suffit le plus souvent.

Pas d’heures calmes. Les notifications locales décalent automatiquement ce qui tomberait entre 22 h et 8 h. Un push, non — l’heure d’envoi est celle de l’expéditeur.

Audience partielle. Un appareil n’entre dans la table qu’après avoir installé la mise à jour et ouvert l’app en étant connecté. Les premiers jours après une publication, l’audience joignable est une fraction de la base ; elle se remplit au rythme des ouvertures.

Un seul canal Android. Les push se posent sur le canal « accompagnement », celui du coach. Conséquence assumée : quelqu’un qui coupe ce canal pour échapper aux annonces coupe aussi les notifications du programme.

Rien de commercial. Une promotion envoyée en push exige, côté Apple, un accord explicite recueilli dans l’app et un moyen de le retirer — règle 4.5.4 de l’App Store. Ce n’est pas la permission système, c’est un consentement séparé, et il n’existe pas encore. Concrètement : annonces, contenu, accompagnement, oui. Offre réduite, code promo, relance d’abonnement, non.

Durée de vie. Un message expire au bout de 24 h. Un téléphone éteint plus longtemps ne le recevra jamais — délibéré : passé un jour, le message est trop périmé pour valoir un réveil.

Force-stop. Un utilisateur Android qui a fait « Forcer l’arrêt » est injoignable : alarmes, tâches et FCM sont tous bloqués par le système jusqu’à la prochaine ouverture manuelle. Non contournable.

Pas d’A/B. Faisable à la main — deux fichiers, deux moitiés d’audience, deux slugs — mais rien ne calcule le résultat.

Prod Dev
Référence projet qegdvexqosnckhogtffj xxzatqdtbteypbfgujfa
Par défaut dans le shell oui non — à préfixer
Alimenté par schémas Prod schémas Dev
push_sent vers PostHog oui non, volontairement

PUSH_SEND_SECRET existe à deux endroits : le ~/.zshrc et les secrets de chaque projet Supabase. Les changer ensemble, ou la commande renverra 401 Unauthorized sans autre explication.

Slug recyclé. Réutiliser un campaign déjà envoyé fait remplacer la bannière précédente sur les téléphones qui l’ont encore, et mélange les deux messages dans le même entonnoir.

Audience vide. "user_ids": [] est refusé — une audience vide est une erreur, pas un envoi. Un --users-from pointant sur un fichier vide donne la même chose.

Mauvais projet. Sans préfixe, la commande part sur prod. Le compte-rendu d’essai à blanc est le garde-fou : si on attendait deux appareils de test et qu’on en voit deux mille, on n’est pas sur dev.

Langue oubliée. Une langue absente de by_locale reçoit default sans prévenir. L’essai à blanc marque ces cas "fallback": true — c’est la ligne à lire avant d’envoyer.

PostHog. Le SDK PostHog collecte les jetons d’appareil par défaut (capturePushNotificationSubscriptions) pour son propre produit push, et son swizzling avalait le callback d’enregistrement iOS — aucun appareil ne s’enregistrait. Les deux options push du SDK sont coupées côté app. À revérifier à chaque montée de version du SDK : un défaut qui revient recasserait l’enregistrement en silence.