Find It Now sur ordinateur portable
Application Web

Find It Now

Objets perdus retrouves par code QR, sans donner son numero ni son adresse. Mandat client.

Technologies

Channels
Django
Next.js
Stripe

A propos du projet

Find It Now colle un QR sur un objet pour que celui qui le trouve joigne le propriétaire sans voir son numéro ni son adresse. Je l'ai construit au complet, front Next.js et back Django Channels, et déployé en production le 14 juin 2026. Le produit est complet et testé de bout en bout en conditions réelles.

Le contexte

La même cliente fondatrice que Send Me Now m'a demandé un deuxième produit bâti sur du QR. Le scénario de départ : un parent colle un QR sur le cartable de son enfant, l'objet se perd, celui qui le ramasse scanne et entre en contact.

La contrainte technique qui tient tout le reste, c'est la vie privée. Le propriétaire doit rester joignable sans être exposé. J'avais déjà écrit le module de QR claimable pour Send Me Now, je l'ai repris comme base.

Le problème

Générer un QR, c'est trivial. Le vrai problème, c'est de décider ce que la page publique affiche, et quand.

Un QR imprimé traîne dans la nature. Il existe avant d'être vendu, il reste collé quand l'objet est bien au chaud à la maison, et il peut être scanné par n'importe qui à n'importe quel moment. Afficher les coordonnées du propriétaire en permanence transformerait chaque objet en fiche d'identité publique. C'est exactement ce que fait déjà l'autocollant avec un numéro de téléphone, gratuitement, et c'est ça que le produit devait battre.

Puis vient le moment où le contact s'ouvre. Deux inconnus doivent se parler, souvent debout dans un stationnement, sur un téléphone, sans envie d'installer une app ni de créer un compte.

Ce que j'ai construit

Une seule URL publique, /q/<slug>, avec quatre états.

  • not_activated : le tag n'appartient à personne, le scanneur voit un bouton « C'est à moi » en un tap.
  • active : l'objet est chez lui, la page ne renvoie rien du tout. Aucun nom, aucune photo, aucun contact.
  • lost et found : la carte de contact apparaît.

Cette page est rendue côté serveur en force-dynamic, jamais mise en cache. C'est la page froide qu'un parent ouvre en scannant, elle ne peut pas servir un état périmé.

Le claim passe par POST /api/activation/claim/, qui lie un tag pré-alloué à un compte et crée l'objet. Les slugs font 8 caractères en [a-z0-9], environ 2 800 milliards de combinaisons, avec un retry sur collision qui vérifie les deux tables, parce qu'un slug peut venir d'un lot imprimé d'avance ou être généré au save.

Pour l'achat en ligne, j'ai branché le mint sur le webhook Stripe. À checkout.session.completed, le lot est créé en transaction atomique et idempotente : l'acheteur reçoit des tags déjà liés à son compte, sans claim manuel, et une re-livraison du webhook rejoue proprement.

La messagerie tourne sur Django Channels en WebSocket, avec les messages stockés dans Redis en TTL 7 jours plutôt qu'en Postgres. Une conversation entre deux inconnus autour d'un objet perdu n'a pas besoin de survivre plus longtemps que ça. Reconnexion en backoff exponentiel plafonné à 16 secondes, et repli en polling toutes les 5 secondes si le WebSocket tombe. Le trouveur peut ouvrir un compte invité pour discuter tout de suite, puis le convertir en vrai compte s'il le veut. Côté traces, aucune IP brute n'est stockée, seulement un SHA256 salé et tronqué.

Le résultat

L'app est construite et déployée en production depuis le 14 juin 2026 : Next.js 16 sur Vercel, Django sur Railway avec Postgres, Redis, un worker et un beat Celery. J'ai testé les flux en live avec deux navigateurs en parallèle, un propriétaire et un trouveur invité : inscription, claim, mode perdu, messagerie temps réel, checkout Stripe en mode test.

Ce test a sorti trois bugs que je n'aurais jamais vus en local. Un message qui s'affichait deux fois à l'envoi, à cause d'une course entre la réponse du POST et l'écho du WebSocket. Un trouveur qui laissait ses coordonnées créait bien un enregistrement, mais aucun écran ni endpoint ne le montrait au propriétaire, qui ne voyait donc jamais rien. Et mon propre correctif est passé à côté : DRF paginait par défaut là où le front attendait un tableau brut.

Le produit n'a pas encore d'utilisateurs. Il lui manque un nom de domaine, un stockage objet et une mise en marché, pas du code.

Côté technique, l'upload de photo réussit mais l'image renvoie 404 en production, faute de stockage objet configuré. Les notifications par courriel et par push sont en pause, faute de nom de domaine. Il n'y a pas de domaine custom, la prod vit sur les alias Vercel et Railway.

Ce que j'en retiens

Déployé ne veut pas dire en production. En voulant générer des QR « de prod », j'ai découvert que Railway tournait en réalité sur config.settings.development : SQLite éphémère et DEBUG=True, pendant qu'un Postgres et un Redis dormaient à côté, inutilisés. La cause : manage.py, asgi.py et celery.py font tous un setdefault vers development, et le Procfile ne pose jamais DJANGO_SETTINGS_MODULE. Toute la sélection reposait sur une variable d'environnement que personne n'avait posée. Pire, railway run manage.py retombait lui aussi sur dev, donc je croyais seeder la prod en touchant la base SQLite de mon laptop.

Même famille de piège plus haut dans la chaîne : la protection de déploiement Vercel était restée active, chaque URL de prod renvoyait un 401 et un mur de login. Sur un site normal, c'est un détail de configuration. Sur un produit dont la porte d'entrée est un téléphone qui scanne un autocollant, c'est le produit au complet qui ne marche pas.

Dans les deux cas, l'environnement affichait une chose et en faisait une autre, et je ne l'ai su qu'en allant vérifier. Depuis, je pose le settings module dans le Procfile plutôt que dans une variable oubliable, et je vérifie l'en-tête Strict-Transport-Security sur le healthcheck pour savoir si je suis vraiment en prod, au lieu de le supposer.

L'autre leçon est administrative. Un accord verbal ne crée pas de dossier : pas de date, pas d'échéancier, rien à relancer, et le développeur porte seul le risque de tout le build. C'est pour ça que je ne démarre plus un mandat sans une entente écrite et un acompte à la signature. Ça protège le client autant que moi : chacun sait ce qui est livré, quand, et à quelles conditions.

Captures d'écran