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.

Polyy — bannière promotionnelle avec un cavalier en saut d'obstacle et l'app sur smartphone, disponible sur App Store et Google Play
Polyy, disponible sur App Store et Google Play.

L’architecture trois-tiers

architecture polyy — trois backendsApp React NativeTypeScript · RN 0.61 · React Navigation · NativeBaseAPI Rails 5.2PostgreSQL + PostGISActive Storage (S3)JBuilder · Sidekiq · RPushFirebaseFirestore (chat + commentaires)Cloud Functions (sync)Remote Config · FCMREST · JWTonSnapshotCloud Function → PATCH /chat_roomsIFCE (SOAP)registre officielTwilioSMS / vérif. telAWS S3photos / vidéos

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 (Firestore pour les résultats temps réel, NestJS pour le reste) et sur le configurateur de tissus (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.

modèle de données — entités principalesUsername · email · phonestable_address (PostGIS point)status: person | structurecategory: Amateur | Pro | Clubdivision: CSO | Dressage | CCE…avatar · facebook_idhorses_count · followers_countHorsename · year_of_birth · nationalitycolor (25 robes) · height (80-200cm)gender: Hongre | Jument | Étalonbreed · is_poney · is_brood_marePedigreefather · mother · mothers_fatherSportdivision · category · jump_heightlevel · future_levelVentesale_state · price_cents · price_rangePostcontent × 4 (photos/vidéos)label · locationcompetition_jump_heightview_count · post_likes_countreportings_count (auto-hide > 3)1..n1..nCouche socialeFollow (user→user)Favorite (user→horse)PostLike (user→post)Reporting (user→post)Rating (user→user)Messagerie (Firebase Firestore + Rails sync)ChatRoom (Rails)last_message · ackMessages (Firestore)rooms/roomId/messagesCommentaires (Firestore)comments/postId/commentsPush (RPush)FCM via Sidekiq

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é.

# 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: {
      nom: name.upcase,
      age: age,
      race: race,
      sexe: gender,
    })
    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 :

# 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(#{longitude} #{latitude})"
      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 :

# app/controllers/api/v1/posts_controller.rb (index action, simplifié)
def index
  scope = Post.visible.includes(:horse, :user)
  scope = scope.joins(:horse).where(horses: { sale_state: "A vendre" }) 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: { division: params[:horse_division] }) 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/{roomId}/messages/{messageId}. 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 :

// 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 :

// functions/src/index.ts
export const forwardLatestProdMessageToRails =
  functions.firestore
    .document("rooms/{roomId}/messages/{messageId}")
    .onCreate(async (snapshot, context) => {
      const data = snapshot.data()
      const { roomId } = context.params

      await axios.patch(
        `${backendUrl}/api/v1/chat_rooms/${roomId}`,
        {
          lastMessage: data.message,
          lastMessagePublishedAt: data.createdAt.toDate(),
          lastMessageUserId: data.user.id,
        },
        { headers: { Authorization: `Bearer ${secretToken}` } }
      )
    })

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 :

// App.tsx (simplifié)
<CurrentUserProvider>
  <HorseProvider>
    <PostProvider>
      <ChatProvider>
        <SearchProvider>
          <TimelineFilterProvider>
            <FirebaseConfigProvider>
              <MainNav />
            </FirebaseConfigProvider>
          </TimelineFilterProvider>
        </SearchProvider>
      </ChatProvider>
    </PostProvider>
  </HorseProvider>
</CurrentUserProvider>

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 :

class Post < ApplicationRecord
  scope :visible, -> { where("reportings_count < 3") }
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.