# Polyy — construire un réseau social de l'achat-vente de chevaux > Retour technique sur Polyy, un réseau social et marketplace pour l'achat et la vente de chevaux. Architecture trois-tiers (React Native + Rails API + Firebase), modèle de données métier riche (pedigree, discipline, performances), messagerie temps réel et recherche géolocalisée avec PostGIS. Date : 15/02/2021 Auteur : Raphael P. Tags : React Native, Ruby on Rails, Firebase, TypeScript, PostgreSQL, Architecture --- Polyy est un réseau social dédié au monde du cheval. L'idée est née d'un constat : les cavaliers, éleveurs et professionnels de l'équitation cherchent, vendent et partagent sur des groupes Facebook, des petites annonces éparpillées et du bouche-à-oreille. Il n'y a pas de plateforme qui combine la dimension sociale (publier des photos/vidéos de ses chevaux, suivre d'autres cavaliers, commenter) et la dimension marché (annonces de vente, recherche par critères équestres, messagerie entre acheteurs et vendeurs). On a construit Polyy de A à Z : l'app mobile React Native (iOS + Android), l'API Rails, l'infrastructure Firebase pour le temps réel, et tout le modèle de données métier. C'est le projet le plus ambitieux qu'on ait mené chez imagine-app côté mobile. ## L'architecture trois-tiers Le choix de séparer Rails et Firebase n'est pas anodin. Rails gère les données structurées (chevaux, utilisateurs, annonces, recherche géolocalisée), les images via Active Storage sur S3, et l'authentification. Firebase gère le temps réel : les messages de chat et les commentaires, via Firestore `onSnapshot`. Une Cloud Function fait le pont : quand un message est posté dans Firestore, elle notifie Rails pour mettre à jour le `last_message` du ChatRoom et déclencher les push notifications via RPush. On a eu le même pattern sur [MyMove](/blog/mymove-comparateur-vtc-microservices-cloud-run) (Firestore pour les résultats temps réel, NestJS pour le reste) et sur le [configurateur de tissus](/blog/configurateur-tissus-react-native-firebase-svg) (Firestore pour le catalogue temps réel). C'est un pattern qu'on connaît bien : REST pour le CRUD, Firestore pour le temps réel. ## Le modèle de données : 25 couleurs de robe et un registre IFCE C'est là que le projet devient intéressant. Un cheval n'est pas un produit générique. Le modèle de données a été conçu avec les professionnels de la filière, et chaque champ a une raison d'être. Un cheval a une robe (25 couleurs possibles : Bai, Alezan, Gris, Noir, Pie, Isabelle, Palomino...), un genre (Hongre, Jument, Étalon), une race, une nationalité, un pedigree (père, mère, père de la mère), une discipline sportive (CSO, Dressage, CCE, Endurance, Hunter, Western, Attelage, Voltige), un niveau actuel et un niveau "à terme", une hauteur de saut en cm, un état de vente, un prix, et un dossier vétérinaire (type de visite, date, commentaire). Le tout est configuré dans un `horse.yml` côté Rails. Les enums ne sont pas codés en dur dans le modèle, ils sont chargés depuis la config. Quand la fédération ajoute une discipline ou qu'une nouvelle couleur de robe est reconnue, on modifie un YAML, pas du code. L'intégration IFCE (Institut Français du Cheval et de l'Équitation) est un plus. Via un client SOAP (oui, du SOAP en 2021, c'est l'API officielle), on interroge le registre national des chevaux pour pré-remplir les fiches. L'utilisateur tape un nom, l'app propose les chevaux correspondants dans le registre, et on importe automatiquement la race, le pedigree, la nationalité. ```ruby # app/services/ifce_service.rb class IfceService def find_horses(name:, age: nil, race: nil, gender: nil) client = Savon.client(wsdl: IFCE_WSDL_URL) response = client.call(:rechercher_equide, message: ) format_results(response.body) end end ``` ## La recherche géolocalisée : PostGIS La recherche de chevaux est le feature le plus utilisé. Les acheteurs filtrent par discipline, catégorie, hauteur, prix, âge, race, et surtout par **distance**. On veut voir les chevaux à moins de 50 km, pas l'inverse. C'est PostGIS qui fait le travail. Chaque utilisateur a une localisation GPS (coordonnées PostGIS), et chaque cheval hérite de la localisation de son propriétaire. La recherche par distance utilise `ST_DistanceSphere` : ```ruby # app/models/horse_search.rb class HorseSearch < Search def results scope = Horse.published.visible scope = scope.where(gender: gender) if gender.present? scope = scope.where(division: division) if division.present? scope = scope.where("height >= ?", height_min) if height_min.present? scope = scope.where("height <= ?", height_max) if height_max.present? scope = scope.where("year_of_birth >= ?", age_max_year) if age_max.present? scope = scope.where(sale_state: Horse::FOR_SALE_STATE) if for_sale_only? if latitude.present? && longitude.present? point = "POINT(# #)" scope = scope.joins(:user) .where("ST_DistanceSphere(users.stable_coordinates, ST_GeomFromText(?)) <= ?", point, distance_max_meters) end scope.order(updated_at: :desc).limit(50) end end ``` Le modèle `Search` utilise du STI (Single Table Inheritance) : `HorseSearch` et `UserSearch` héritent de `Search` et partagent la même table avec un champ `type`. Les critères de recherche sont stockés en JSON dans un champ `filters`, ce qui permet de sauvegarder les recherches préférées des utilisateurs. ## Le feed social : timeline avec filtres La timeline est le coeur de l'expérience sociale. Les posts sont des photos ou vidéos de chevaux, avec un cheval associé, un lieu, et optionnellement des données de compétition (hauteur de saut, niveau). Chaque post peut avoir jusqu'à 4 médias. Le feed est filtrable en temps réel. L'utilisateur peut affiner par : posts des personnes suivies uniquement, type de média (photo/vidéo), chevaux à vendre, discipline, catégorie, et rayon géographique. Côté Rails : ```ruby # app/controllers/api/v1/posts_controller.rb (index action, simplifié) def index scope = Post.visible.includes(:horse, :user) scope = scope.joins(:horse).where(horses: ) if params[:horse_for_sale] scope = scope.where(user_id: current_user.followed_user_ids) if params[:from_followed_users] scope = scope.joins(:horse).where(horses: ) if params[:horse_division] scope = scope.where("posts.created_at > ?", 2.weeks.ago).order(created_at: :desc) scope = scope.limit(params[:limit] || 20).offset(params[:offset] || 0) @posts = scope end ``` Côté React Native, les filtres sont gérés par un `TimelineFilterContext` qui cache les préférences localement. Le changement de filtre provoque un rechargement du feed depuis l'API, sans rechargement de la page grâce au state management par Context. ## La messagerie hybride Rails + Firebase C'est la partie la plus intéressante de l'architecture. On utilise deux systèmes pour la messagerie parce qu'aucun ne fait tout ce dont on a besoin. **Firestore** gère les messages eux-mêmes : `rooms//messages/`. Le `onSnapshot` de Firestore push les nouveaux messages en temps réel vers l'app sans polling. On utilise RxJS côté React Native pour transformer les snapshots Firestore en flux réactifs : ```typescript // src/google/firebase/messages.ts const messages$ForRoomId = (roomId: string) => collectionData( messagesRefForRoomId(roomId).orderBy("createdAt", "desc") ).pipe( map(docs => docs.map(formatMessage)), startWith([]), shareReplay(1) ) ``` **Rails** gère les métadonnées des conversations : qui parle à qui, le dernier message (pour la liste des conversations), et le flag d'acquittement (`ack`) qui détermine si l'utilisateur a vu les nouveaux messages. Le pont entre les deux, c'est une **Cloud Function Firebase** qui écoute les écritures dans Firestore et notifie Rails : ```typescript // functions/src/index.ts functions.firestore .document("rooms//messages/") .onCreate(async (snapshot, context) => { const data = snapshot.data() const = context.params await axios.patch( `$/api/v1/chat_rooms/$`, , { headers: { Authorization: `Bearer $` } } ) }) ``` Rails reçoit le PATCH, met à jour le `ChatRoom`, et déclenche une push notification via RPush (Firebase Cloud Messaging) pour le destinataire. Le cycle complet : l'utilisateur tape un message → Firestore le stocke → la Cloud Function notifie Rails → Rails envoie une push → le destinataire reçoit la notification → ouvre l'app → le `onSnapshot` Firestore affiche le message instantanément. ## L'app React Native : TypeScript, Context et HOC L'app mobile est un projet React Native 0.61 en TypeScript. Pas de Redux : on utilise le Context API avec des Higher-Order Components (via Recompose) pour la composition. Neuf providers imbriqués gèrent chacun un domaine : ```typescript // App.tsx (simplifié) ``` L'API client est **générée automatiquement** depuis la spec OpenAPI du backend Rails. On exécute `yarn build-api` et ça produit les types TypeScript et les fonctions d'appel pour tous les endpoints. Quand le backend change, on régénère. Les erreurs de typage sont détectées à la compilation, pas en production. La navigation utilise React Navigation v3 avec cinq onglets : Feed, Favoris, Publier, Rechercher, Profil. Chaque onglet est un Stack Navigator. Le mode invité restreint l'accès : seul le Feed est disponible, les autres onglets redirigent vers l'inscription. ## La modération : auto-masquage par signalement On a implémenté un système de modération communautaire. Un utilisateur peut signaler un post (`Reporting`). À trois signalements, le post est automatiquement masqué. Côté Rails : ```ruby class Post < ApplicationRecord scope :visible, -> end ``` C'est simple et ça fonctionne. Les posts signalés ne disparaissent pas de la base, ils sont juste filtrés des requêtes. Un admin peut les restaurer via ActiveAdmin si le signalement est abusif. ## Ce qu'on a construit 31 modèles ActiveRecord, 12 controllers API, 4 services (Crypto, Facebook, Twilio, IFCE), 289 fichiers TypeScript côté React Native, 34 HOC/wrappers, 69 composants réutilisables, et 2 Cloud Functions Firebase. L'app est sur l'App Store et le Play Store. Le projet a duré un an et demi, avec des phases de conception longues sur le modèle de données métier. Le plus gros du travail n'est pas dans le code, c'est dans la compréhension du domaine. Savoir qu'un Connemara de 150 cm peut être classé "poney" même si ça ne ressemble pas à un Shetland, que le père de la mère est une information aussi importante que le père, et que la hauteur de saut "à terme" est l'info que l'acheteur regarde en premier, ça ne s'invente pas. On a passé des heures avec des cavaliers, des éleveurs et des courtiers avant d'écrire la première ligne de code.