Laravel
Angular
Health-Tech

La première application dédiée aux approches non médicamenteuses (INM) scientifiquement validées.

Kalya Pro

📋 Table des matières

  1. Présentation du projet
  2. Architecture globale
  3. Stack technique
  4. Le SSO — Authentification centralisée & applications internes
  5. L'API — Le cœur métier
  6. L'Application Frontend
  7. La recherche full-text avec Meilisearch
  8. Système de traduction multi-langue
  9. Dark Mode & Design System
  10. Modèle économique & gestion des abonnements
  11. Déploiement mobile avec Capacitor
  12. Observabilité & Analytics

1. 📌 Présentation du projet

Le concept

Kalya Pro est un projet sur lequel je travaille depuis 4 ans. C'est la première application dédiée aux approches non médicamenteuses (INM) scientifiquement validées. L'objectif : répertorier, évaluer et restituer en temps réel les connaissances scientifiques disponibles sur des approches comme :

  • 🌿 La phytothérapie et l'aromathérapie
  • 💊 La micronutrition et les compléments alimentaires
  • 🧘 Les approches corps-esprit et thérapies manuelles
  • 🏃 L'activité physique adaptée
  • 🧠 Les thérapies brèves et interventions psychosociales

L'efficacité de chaque intervention est évaluée via un Kalya Score propriétaire, un algorithme que j'ai implémenté en collaboration avec une équipe de recherche universitaire.

Un projet qui a mûri sur 4 ans

Ce projet m'a accompagné à travers plusieurs générations technologiques. Le codebase a traversé des migrations majeures au fil des ans :

Période Frontend Backend Événement clé
2022 Angular 14 Laravel 9 Première version de l’application, i18n natif
2023 Angular 15 → 16 Laravel 9 Intégration de Capacitor pour le mobile, recherche Meilisearch, migration vers un nouveau repo
2024 Angular 17 → 18 Laravel 10 Refactoring majeur : nouveau repo API, création du SSO, ajout de Stripe, refonte de l’architecture i18n (translation tables)
2025–26 Angular 18 Laravel 10 Stabilisation, optimisations de performance

Chaque montée de version a été l'occasion de refactoriser en profondeur — passer de ngModules à une architecture plus modulaire, migrer les tokens d'authentification, réadapter les pipelines de build mobile, etc. Ce cycle continu de refactoring m'a appris à concevoir du code qui anticipe le changement.

Les cibles

Audience Accès Usage
Grand public Freemium Découverte des INM, veille scientifique
Professionnels de santé Premium Évaluations détaillées, bibliographie, outils de prescription
Partenaires (marques) B2B Référencement produits, marketplace

Le site commercial — kalyapro.fr

Page d'accueil Kalya Pro

J'ai conçu le site vitrine avec un design professionnel, un dégradé teal/bleu marine qui renforce la crédibilité scientifique du projet. Le hero section présente des mockups de l'app, des témoignages de professionnels de santé, et un CTA vers une démonstration Calendly.

L'application en production — app.kalyapro.com

Application Kalya Pro

Pour l'interface applicative, j'ai fait le choix d'un thème sombre professionnel — un choix délibéré pour un outil "expert" qui réduit la fatigue visuelle lors de longues sessions de recherche. La sidebar de navigation, la barre de recherche proéminente et le dashboard de contenu recommandé structurent l'expérience utilisateur.


2. 🏗️ Architecture globale

Illustration architecture

Dès le départ, j'ai fait le choix d'une architecture en 3 briques indépendantes, chacune dans son propre repository Git. Cette séparation, que j'ai mise en place dès la refactorisation de 2023, a été l'une de mes meilleures décisions architecturales :

graph TB
    subgraph "Client"
        APP["📱 kalya-pro-app<br><b>Angular 18 + Capacitor 6</b><br>v4.4.3"]
    end

    subgraph "Backend"
        API["⚙️ kalya-pro-api<br><b>Laravel 10</b><br>v4.3.0"]
        SSO["🔐 kalya_pro_sso<br><b>Laravel 10 + Passport</b><br>v1.1.11"]
    end

    subgraph "Applications internes"
        ADMIN["🖥️ Apps de gestion<br><b>Intranet Kalya</b>"]
    end

    subgraph "Services tiers"
        MS["🔍 Meilisearch"]
        STRIPE["💳 Stripe"]
        PUSHER["📡 Pusher"]
        SENTRY["🛡️ Sentry"]
        MATOMO["📊 Matomo"]
    end

    APP -->|"REST API + JWT"| API
    APP -->|"OAuth 2.0"| SSO
    ADMIN -->|"OAuth 2.0"| SSO
    ADMIN -->|"REST API"| API
    API -->|"Search"| MS
    API -->|"Payments"| STRIPE
    API -->|"WebSocket"| PUSHER
    APP -->|"Error tracking"| SENTRY
    APP -->|"Analytics"| MATOMO
    SSO -->|"Social Login"| GOOGLE["Google"]
    SSO -->|"Social Login"| APPLE["Apple"]
    SSO -->|"Social Login"| MICROSOFT["Microsoft"]

Pourquoi j'ai choisi cette séparation

Critère Avantage concret
Indépendance Chaque brique évolue à son rythme — le SSO est en v1.1.11, l'API en v4.3.0
Réutilisabilité Le SSO sert à la fois l'app publique et les applications intranet de gestion des données
Sécurité L'isolation du SSO réduit la surface d'attaque sur les credentials
Maintenance Un bug dans l'API n'impacte pas l'authentification ni les outils internes

3. 🧰 Stack technique

Vue d'ensemble

graph LR
    subgraph Frontend
        A[Angular 18] --> B[TailwindCSS 3]
        A --> C[Capacitor 6]
        A --> D[Transloco i18n]
        A --> E[RxJS]
        C --> F[Android]
        C --> G[iOS]
        C --> H[Web/PWA]
    end

    subgraph Backend
        I[Laravel 10] --> J[PHP 8.1+]
        I --> K[Eloquent ORM]
        I --> L[Laravel Scout]
        I --> M[Laravel Cashier]
        I --> N[Laravel Socialite]
    end

    subgraph Services
        O[MySQL]
        P[Meilisearch]
        Q[Stripe]
        R[Redis/Cache]
    end

    I --> O
    L --> P
    M --> Q
    I --> R

Après 4 ans d'itérations, j'ai convergé vers une stack que je maîtrise parfaitement et qui a prouvé sa résilience face aux montées de version et aux pivots fonctionnels.

Détail des dépendances clés

Frontend — kalya-pro-app

Catégorie Technologie Pourquoi ce choix
Framework Angular 18.1 Robustesse, typage fort, architecture modulaire mature
Styling TailwindCSS 3.4 Productivité + dark mode natif
Mobile Capacitor 6.x Un seul codebase pour 3 plateformes
i18n Transloco 7.4 Plus flexible qu'Angular i18n, lazy loading des traductions
Paiement ngx-stripe 18.1 Intégration Stripe côté client, conforme PCI
Recherche Meilisearch JS Instant search avec tolérance aux fautes de frappe
Icônes FontAwesome Pro 6.2 Pack complet (duotone, thin, etc.)
Markdown ngx-markdown 18.1 Rendu du contenu scientifique riche
Analytics ngx-matomo-client Tracking respect RGPD
Monitoring Sentry Angular 8 Error tracking en production
Real-time Laravel Echo + Pusher WebSocket pour notifications

Backend — kalya-pro-api

Catégorie Technologie Pourquoi ce choix
Framework Laravel 10.10 Écosystème complet, ORM puissant
Traductions astrotomic/laravel-translatable Pattern translation-table éprouvé
Recherche Laravel Scout + Meilisearch Full-text search performante et simple
Paiement Laravel Cashier Abstraction complète de Stripe
Auth social Laravel Socialite Multi-providers en quelques lignes
Relations eloquent-has-many-deep Navigation de relations complexes
Qualité Laravel Pint Formatage cohérent du code

SSO — kalya_pro_sso

Catégorie Technologie Pourquoi ce choix
OAuth Laravel Passport 11.9 Serveur OAuth 2.0 complet, multi-clients
Frontend Inertia.js + Vue Interface de connexion SSR, rapide
Auth social Socialite (Google, Apple, Microsoft) Login social multi-providers
Géolocation torann/geoip Détection automatique du pays
UI Laravel Jetstream 4 Scaffolding auth robuste

4. 🔐 Le SSO — Authentification centralisée & applications internes

Mon choix architectural

Très tôt dans le projet, j'ai décidé d'externaliser l'authentification dans un service dédié (kalya_pro_sso). Ce serveur OAuth 2.0 basé sur Laravel Passport ne se contente pas d'authentifier les utilisateurs de l'app publique — il sert aussi de point d'entrée unique pour les applications internes de gestion des données Kalya Pro (backoffice, outils de modération, interfaces d'administration).

Cette mutualisation signifie qu'un éditeur ou un administrateur se connecte une seule fois pour accéder à l'ensemble des outils — qu'il s'agisse de l'app publique, du backoffice de contenu, ou des outils d'analyse.

Flux d'authentification

sequenceDiagram
    participant User as 👤 Utilisateur
    participant App as 📱 Angular App / 🖥️ Intranet
    participant SSO as 🔐 SSO Server
    participant API as ⚙️ API Server
    participant Social as 🌐 Social Provider

    User->>App: Ouvre l'application
    App->>SSO: Redirect OAuth (authRedirect)

    alt Login Social
        SSO->>Social: OAuth Request (Google/Apple/MS)
        Social-->>SSO: Authorization Code
    else Login Email
        User->>SSO: Email + Password
    end

    SSO-->>App: Code d'autorisation (redirect callback)
    App->>API: POST /auth/callback {code}
    API->>SSO: Vérifie le code + échange token
    SSO-->>API: Access Token + Refresh Token
    API-->>App: JWT Token (access + refresh)

    loop Requêtes authentifiées
        App->>API: Request + Bearer Token
        API-->>App: Response
    end

    Note over App,API: Token expiré ?
    App->>API: POST /auth/refresh {refresh_token}
    API-->>App: Nouveau JWT

Modèles de données du SSO

J'ai structuré le SSO autour de 10 modèles indépendants de l'API métier :

Modèle Rôle
User Compte utilisateur central, partagé entre toutes les apps
UserSetting Préférences (langue, dark mode, etc.)
UserSocial Liens avec les comptes sociaux
Provider Providers OAuth enregistrés
Client Clients OAuth — chaque application (publique ou interne) est un client
Session Sessions actives
Role / Groupe RBAC groupé pour gérer les droits admin/éditeur/utilisateur
AuthPartenaire Authentification via partenaires B2B (ex: ADNR)
MatomoUser Liaison analytics

5. ⚙️ L'API — Le cœur métier

Ma modélisation du domaine

Le domaine métier de Kalya Pro est riche et complexe — c'est d'ailleurs ce qui m'a le plus challengé techniquement. J'ai conçu une architecture avec 78 modèles Eloquent répartis en plusieurs domaines fonctionnels, articulés autour de la relation centrale "un problème de santé est évalué par des interventions non médicamenteuses" :

erDiagram
    HealthProblem ||--o{ HealthProblemNpi : "évalué par"
    Npi ||--o{ HealthProblemNpi : "évalue"
    HealthProblemNpi ||--o{ HealthProblemNpiOutcome : "a des résultats"
    HealthProblemNpi ||--o{ HealthProblemNpiPublication : "basé sur"
    Publication ||--o{ HealthProblemNpiPublication : "référence"

    HealthProblem ||--o{ HealthProblemCateg : "catégorisé dans"
    Npi ||--o{ NpiCateg : "catégorisé dans"

    HealthProblem }o--o{ MedProfil : "concerne"
    HealthProblem }o--o{ Symptom : "a comme symptôme"
    Npi }o--o{ MedProfil : "applicable par"

    HealthProblem }o--o{ Feed : "suivi via"
    Feed }o--o{ Post : "contient"

    User }o--o{ Feed : "s'abonne à"
    User ||--o{ Subscription : "souscrit"

    Product }o--o{ Supplier : "vendu par"
    Product }o--o{ FeatureProduct : "a comme caractéristique"

    HealthProblem {
        int id PK
        string slug
        string img
        json actives_scores
        boolean exhaustive
    }

    Npi {
        int id PK
        string slug
        string img
    }

    HealthProblemNpi {
        int id PK
        float kalya_score
        float biblio_count
    }

Pattern: le modèle HealthProblem

Le modèle HealthProblem illustre bien les patterns que j'ai mis en place au fil des refactors :

class HealthProblem extends Model implements TranslatableContract, SearchableContract
{
    use HasFactory, Translatable, HidesBasicsAttributes,
        HasRelationships, HasIdOrSlugScope, Searchable;

    // Attributs traduits automatiquement selon la locale
    public $translatedAttributes = ['name', 'determinant', 'infos', 'description'];

    protected $casts = [
        'actives_scores' => 'json',      // Scores sérialisés en JSON
        'exhaustive' => 'boolean',
        'img' => AssetUrlCast::class.':health_problem',  // Cast custom pour l'URL
    ];

    // Relations profondes (model HasManyDeep)
    public function posts()
    {
        return $this->hasManyDeepFromRelations(
            $this->feeds(),
            (new Feed)->posts()
        );
    }

    // Calcul dynamique des scores actifs
    public function calcActivateScore()
    {
        $scores = [];
        foreach (HealthProblemNpiController::SCORES as $score) {
            $scores[$score] = (
                $this->evaluations()->get()
                    ->where($score, '!==', null)
                    ->count() > 0
            );
        }
        return json_decode(json_encode($scores));
    }
}

Ce que ce code montre de mon approche :

  • 🔄 Composition par traits — 6 traits mixin combinés pour gérer traduction, recherche, visibilité, slug, relations profondes et attributs masqués. Ce design a émergé progressivement au fil des refactors.
  • 🌍 Transparence multilingue — les attributs name, infos, description sont résolus automatiquement selon la locale, sans que le contrôleur n'ait à s'en soucier.
  • 🔢 AssetUrlCast — un cast personnalisé que j'ai développé pour transformer les chemins d'images en URLs absolues, selon l'environnement.
  • 🌊 hasManyDeepFromRelations — navigation HealthProblem → Feed → Post en une seule requête, essentielle pour les performances.

Ma stratégie de cache

J'ai implémenté un système de cache à deux niveaux pour gérer des données rarement modifiées mais intensément consultées :

// Cache permanent pour les données stables (catégories, listings)
return self::cacheRememberForever('healthProblem.index', function () {
    return HealthProblem::all();
});

// Cache dynamique avec clé composite pour les données filtrées
$key = 'multi_' . $sortType . '_' . implode('_', $hps);
if ($filters) {
    sort($filters);
    $key .= '_'.implode('_', $filters);
}
return self::cacheRemember($key, function () use ($hps, $sortType, $filters) {
    // Requête complexe exécutée une seule fois
});

Les clés de cache sont construites dynamiquement — chaque combinaison de tri/filtres/IDs génère une clé unique, garantissant un cache granulaire mais efficace. Le sort() sur les filtres avant concaténation évite les doublons de cache pour des requêtes identiques ordonnées différemment.

Structure des routes API

J'ai conçu une API RESTful sécurisée avec un middleware d'authentification et un gating premium :

/auth/callback              POST    # OAuth callback
/health_problem             GET     # Liste des problèmes de santé
/health_problem/{hp}        GET     # Détail d'un HP
/health_problem/{hp}/npi    GET     # INM pour un HP
/health_problem/{hp}/npi/{npi}              GET 🔒 PREMIUM
/health_problem/{hp}/npi/{npi}/references   GET

/npi                        GET     # Liste des INM
/npi/{npi}                  GET     # Détail d'une INM
/npi/{npi}/safety           GET 🔒 PREMIUM

/me                         GET     # Profil utilisateur
/me/saves                   GET/POST/DELETE  # Favoris
/me/subscribe               POST/DELETE      # Abonnement
/me/feed                    POST    # Flux personnalisé

/search                     GET     # Recherche globale
/product                    GET     # Marketplace produits

6. 📱 L'Application Frontend

Mon architecture Angular

J'ai organisé l'application selon une architecture modulaire par domaine métier. Au fil des refactors et des montées de version Angular (14 → 18), cette structure a prouvé sa solidité :

src/app/
├── pages/                 # Navigation par fonctionnalité
│   ├── content/           # ── Le cœur de l'app ──
│   │   ├── home/          #    Dashboard principal
│   │   ├── health-problem/#    Fiches problèmes de santé
│   │   ├── hp-npi/        #    Évaluations HP ↔ NPI
│   │   ├── npi/           #    Fiches interventions
│   │   ├── guided-search/ #    Recherche guidée
│   │   ├── search/        #    Recherche libre
│   │   ├── news/          #    Actualités scientifiques
│   │   ├── post/          #    Articles
│   │   ├── publication/   #    Publications scientifiques
│   │   ├── product/       #    Marketplace
│   │   ├── favorites/     #    Éléments sauvegardés
│   │   ├── settings/      #    Paramètres
│   │   ├── faq/           #    FAQ
│   │   └── contact/       #    Contact
│   ├── login/             # ── Authentification ──
│   │   ├── login-form/    #    Formulaire de login
│   │   ├── register/      #    Inscription
│   │   ├── subscribe/     #    Souscription premium
│   │   ├── carousel/      #    Onboarding
│   │   ├── forgotpass/    #    Mot de passe oublié
│   │   └── resetpass/     #    Reset password
│   ├── loading/           # Page de chargement
│   └── complete-profil/   # Complétion du profil
│
└── shared/                # Éléments réutilisables
    ├── service/           # 13 services injectables
    ├── model/             # 35 interfaces TypeScript
    ├── guard/             # 3 guards (Auth, Premium, HpNpi)
    ├── pipe/              # Pipes personnalisés
    ├── directive/         # Directives
    └── layout/            # Composants UI
        ├── base/          #    Prefix kp-* (boutons, nav...)
        ├── card/          #    Cards métier spécialisées
        ├── kalyaPro/      #    Composants domaine Kalya
        ├── misc/          #    Alert, footer, utilitaires
        └── postPage/      #    Composants articles

Le Service API centralisé — Mon chef d'orchestre

L'ApiService (609 lignes) est le point névralgique que j'ai conçu pour orchestrer toute la communication de l'app :

@Injectable({ providedIn: 'root' })
export class ApiService {
  private token: any;
  public user: User | undefined;
  public inited = false;
  public initedEvent = new Subject<boolean>();
  private destroy$ = new Subject<void>();

  // Cache de validation du token (optimisation performance)
  private tokenValidityCache: { isValid: boolean; expires: number } | null = null;

  constructor(
    private http: HttpClient,
    private router: Router,
    private translocoService: TranslocoService,
    private onlineStatusService: OnlineStatusService,
    private zone: NgZone,
    private matomoTracker: MatomoTracker,
  ) {
    this.init();
  }

Mon pattern d'initialisation parallélisée :

Un des refactors dont je suis le plus fier. L'initialisation séquentielle originale bloquait l'UI pendant 3-5 secondes. J'ai redesigné le flow avec Promise.allSettled pour paralléliser les opérations :

private async initializeAppParallel(): Promise<void> {
  const operations: Promise<any>[] = [];

  // Auth (critique)
  if (this.urlParams.has('code')) {
    operations.push(this.requestToken());
  } else {
    const jsonToken = localStorage.getItem('auth_token');
    if (jsonToken) {
      operations.push(this.renewToken());
    }
  }

  // Opérations non-critiques en parallèle
  operations.push(Promise.resolve(this.logApp()));
  operations.push(Promise.resolve(this.initWs()));

  // Exécution parallèle !
  const results = await Promise.allSettled(operations);

  // Vérification ciblée de l'auth uniquement
  if (results[0].status === 'rejected') {
    this.authRedirect();
    return;
  }

  // Background tasks (non-bloquant)
  this.updateUserCountryBackground();
}

Mon système de requêtes HTTP avec headers enrichis :

Chaque requête envoyée à l'API embarque automatiquement des métadonnées de contexte :

public async requestApi(action: string, method: string = 'GET', ...): Promise<any> {
  // Validation token avant chaque requête (avec cache 30s)
  if (!action.includes('/auth/refresh')) {
    await this.validityToken();
  }

  // Headers automatiques pour tracking côté serveur
  const headers = {
    'X-Route': pageData['name'],           // Route Angular courante
    'X-Resource-Type': pageData['resource_type'],
    'X-Resource-Id': resource_id(),
    'X-Url': window.location.pathname,
    'X-Platform-Origin': Capacitor.getPlatform(),  // web/ios/android
  };
  // → L'API sait exactement d'où vient chaque requête
}

Mon système de Guards

J'utilise 3 guards qui s'empilent comme des couches de validation :

graph TD
    R[Route demandée] --> AG{AuthGuard}
    AG -->|Non connecté| SSO[Redirect SSO]
    AG -->|Connecté| PG{PremiumGuard}
    PG -->|Pas premium| UP[Upsell Page]
    PG -->|Premium OK| HNG{HpNpiGuard}
    HNG -->|Validation métier| P[✅ Page accessible]

    style SSO fill:#EB5C60,color:white
    style UP fill:#FBAE0A,color:white
    style P fill:#70B62C,color:white

7. 🔍 La recherche full-text avec Meilisearch

Mon architecture de recherche

L'un des chantiers les plus stimulants : concevoir un système de recherche unifié multi-modèles et multi-langues. J'ai d'abord expérimenté avec Algolia avant de migrer vers Meilisearch pour des raisons de coût et de souveraineté des données.

graph TB
    subgraph "Frontend"
        SB["🔍 Barre de recherche"]
        IS["InstantSearch.js"]
    end

    subgraph "API"
        SC["SearchController"]
        SS["SearchService"]
        TR["Trait Searchable"]
    end

    subgraph "Meilisearch"
        FR["📇 fr_global<br>(index français)"]
        EN["📇 en_global<br>(index anglais)"]
    end

    subgraph "Models Searchable"
        HP["HealthProblem"]
        NPI["Npi"]
        POST["Post"]
        PROD["Product"]
    end

    SB --> IS
    IS --> SC
    SC --> SS
    SS --> FR
    SS --> EN
    TR --> FR
    TR --> EN
    HP --> TR
    NPI --> TR
    POST --> TR
    PROD --> TR

Le trait Searchable — Mon wrapper d'indexation multi-locale

J'ai développé un trait Searchable qui étend Laravel Scout pour ajouter la gestion des locales :

trait Searchable
{
    use \Laravel\Scout\Searchable {
        \Laravel\Scout\Searchable::searchableAs as scoutSearchableAs;
    }

    public function toSearchableArray(): array
    {
        // Mon système de ranking par type de contenu
        $typeRank = match(class_basename($this)) {
            'HealthProblem' => 3,  // Toujours en premier
            'Npi' => 2,
            default => 1
        };

        return array_merge([
            'id' => $this->id,
            'type' => class_basename($this),
            'type_rank' => $typeRank,
            'composite_id' => Str::snake(class_basename($this)) . '-' . $this->id,
            'resource' => $this->load($this->getSearchableRelations())->toArray(),
        ], $this->getSearchableAttributes());
    }

    // Un index par langue — chaque locale a son propre index Meilisearch
    public function searchableAs(): string
    {
        return config('scout.global_index_base_name') . app()->getLocale();
    }

    // Ne pas indexer si pas de traduction disponible
    public function shouldBeSearchable(): bool
    {
        return $this->translations()
            ->where('locale', app()->getLocale())
            ->exists();
    }
}

Mes choix clés :

  • 📊 Ranking par type — les HealthProblem sont toujours priorisés, car c'est le point d'entrée principal de l'utilisateur
  • 🌍 Un index par languefr_global, en_global — chaque langue a son propre index, ce qui évite la pollution inter-langues dans les résultats
  • Indexation conditionnelle — un modèle n'est indexé que s'il possède une traduction dans la locale courante

Le SearchService — Requêtage unifié

class SearchService
{
    public function search(
        string $query,
        array|string|null $type = null,
        int $page = 1,
        int $perPage = 15,
        string $indexName = null
    ): LengthAwarePaginator {
        if (!$indexName) {
            $indexName = config('scout.global_index_base_name') . app()->getLocale();
        }

        $searchParams = [
            'page' => $page,
            'hitsPerPage' => $perPage,
            'matchingStrategy' => 'all',  // Tous les mots doivent matcher
        ];

        // Filtrage flexible par type (string ou array)
        if (is_array($type)) {
            $types = collect($type)->map(fn($t) => "'{$t}'")->join(', ');
            $searchParams['filter'] = "type IN [{$types}]";
        } elseif ($type) {
            $searchParams['filter'] = "type = '{$type}'";
        }

        $results = $index->search($query, $searchParams);

        return new LengthAwarePaginator(
            items: $results->getHits(),
            total: $results->getTotalHits(),
            perPage: $perPage,
            currentPage: $page,
        );
    }
}

8. 🌍 Système de traduction multi-langue

Le pattern Translation Table

Pour gérer le contenu multilingue (français, anglais), j'ai adopté le package astrotomic/laravel-translatable qui sépare les données structurelles des contenus traduits :

erDiagram
    health_problems ||--o{ health_problem_translations : "1:N"
    npis ||--o{ npi_translations : "1:N"

    health_problems {
        int id PK
        string img
        string slug
        json actives_scores
        boolean exhaustive
    }

    health_problem_translations {
        int id PK
        int health_problem_id FK
        string locale
        string name
        string determinant
        text infos
        text description
    }

J'ai appliqué ce pattern uniformément sur tous les modèles de contenu :

Modèle principal Table de traduction Champs traduits
HealthProblem health_problem_translations name, determinant, infos, description
Npi npi_translations name, description, description_gp
Post post_translations title, content, excerpt
Product product_translations name, description
Outcome outcome_translations name
... ... ...

💡 Au total, 77 migrations gèrent ce schéma — environ la moitié sont des tables *_translations. C'est un investissement initial important, mais la cohérence et la scalabilité en valent la peine.

Middleware ApplyLocal

La locale est automatiquement détectée et appliquée de façon transparente :

Requête HTTP  →  Header "Locale: fr"  →  ApplyLocal Middleware  →  app()->setLocale('fr')
                                                                 → Toutes les queries Eloquent
                                                                   retournent le contenu FR

9. 🎨 Dark Mode & Design System

Mon design system médical sur mesure

J'ai conçu l'identité visuelle de Kalya Pro avec un focus sur la lisibilité et la réduction de la fatigue visuelle — essentiel pour un outil utilisé quotidiennement par des professionnels de santé.

Ma palette de couleurs

Couleurs principales
━━━━━━━━━━━━━━━━━━━
  🟦 dark-blue    #096C8C   ██████████  Accent sombre
  🔵 blue         #009DBE   ██████████  Principal
  🟢 teal         #54B195   ██████████  Secondaire
  🟩 green        #70B62C   ██████████  Score élevé
  🟩 dark-green   #44A365   ██████████  Succès
  🟡 orange       #FBAE0A   ██████████  Warning
  🔴 red          #EB5C60   ██████████  Danger

Scores Kalya (dégradé d'efficacité)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Score 0  #888888  ██  Pas de données
  Score 1  #096C8C  ██  Faible
  Score 2  #009DBE  ██  Modéré
  Score 3  #54B195  ██  Bon
  Score 4  #70B62C  ██  Fort

Les couleurs de score forment un dégradé intentionnel du froid (faible) au chaud (fort), permettant une lecture instinctive de l'efficacité.

Mon système de thèmes Light/Dark

Le dark mode est piloté par la classe CSS dark sur le <html> :

// tailwind.config.js
module.exports = {
  darkMode: "class",
  theme: {
    colors: {
      light: {
        background: "#FDFDFF",
        text: "#888888",
        heading: "#2A343C",
        bordure: "#CCCCCC",
      },
      dark: {
        background: "#232C33", // Fond sombre ardoise
        text: "#9BA5AE",
        heading: "#EAEEF2",
        "card-background": "#323F4A",
        input: "#3E4A54",
      },
    },
  },
};

Composants utilitaires Tailwind custom

J'ai développé un système de génération d'alertes colorées intégré à la config Tailwind :

function genAlert(theme, colors) {
  let alerts = {};
  for (var i = 0; i < colors.length; i++) {
    alerts[".alert-" + colors[i]] = {
      padding: theme("spacing.3"),
      borderRadius: theme("borderRadius.lg"),
      transition: "opacity 300ms ease-in-out",
      backgroundColor: theme("colors." + colors[i] + ".200"),
      color: theme("colors." + colors[i] + ".900"),
      "& fa-icon": {
        color: theme("colors." + colors[i] + ".500"),
      },
    };
  }
  return alerts;
}

Breakpoints et responsive

Le layout est optimisé du mobile au 4K avec 7 breakpoints :

xs   ─── 512px  ─── Mobile large
sm   ─── 640px  ─── Tablette portrait
md   ─── 768px  ─── Tablette paysage
lg   ─── 1024px ─── Desktop
xl   ─── 1280px ─── Desktop large → Container: 1075px
2xl  ─── 1536px ─── Wide screen   → Container: 1386px
3xl  ─── 1792px ─── Ultra wide
4xl  ─── 2048px ─── 4K

10. 💳 Modèle économique & gestion des abonnements

Modèle Freemium/Premium

graph TB
    subgraph "Freemium"
        F1["🆓 Liste des problèmes de santé"]
        F2["🆓 Liste des INM"]
        F3["🆓 Actualités scientifiques"]
        F4["🆓 Recherche basique"]
    end

    subgraph "Premium"
        P1["💎 Évaluations détaillées HP↔NPI"]
        P2["💎 Fiches sécurité/contre-indications"]
        P3["💎 Bibliographie complète"]
        P4["💎 Marketplace produits"]
        P5["💎 Outils de prescription"]
    end

    style F1 fill:#54B195,color:white
    style F2 fill:#54B195,color:white
    style F3 fill:#54B195,color:white
    style F4 fill:#54B195,color:white
    style P1 fill:#009DBE,color:white
    style P2 fill:#009DBE,color:white
    style P3 fill:#009DBE,color:white
    style P4 fill:#009DBE,color:white
    style P5 fill:#009DBE,color:white

Mon implémentation technique

J'ai implémenté le gating premium à 3 niveaux pour garantir la sécurité :

1. Middleware Backend (PremiumMiddleware) — Vérification côté serveur, impossible à contourner

Route::get('{npi}', [HealthProblemNpiController::class, 'show'])
    ->middleware('can:isPremium');

2. Guard Frontend (PremiumGuard) — UX fluide côté client

public isPremium(): boolean {
  return !!this.user?.subscription.active;
}

public notPaid(): boolean {
  return this.user?.subscription.status === 'past_due' && !this.isPremium();
}

3. Intégration Stripe — Cycle de vie complet des abonnements

API                          Stripe
 │                             │
 ├── kalya:syncProduct ──────► Sync prix & coupons
 │                             │
 ◄── Webhook ──────────────────┤ Événement de paiement
 │                             │
 ├── StripeEventListener ─────►│
 │   ├── subscription.created  │
 │   ├── subscription.updated  │
 │   ├── payment.failed        │
 │   └── subscription.deleted  │
 │                             │
 ├── In-App Purchase ─────────►│ (via Capacitor)
 │   └── cordova-plugin-purchase│

11. 📱 Déploiement mobile avec Capacitor

Mon architecture native

Capacitor 6 me permet de déployer l'application Angular sur 3 plateformes depuis un seul codebase — un gain de temps considérable sur 4 ans de développement :

// capacitor.config.ts
const config: CapacitorConfig = {
  appId: "com.kalyapro.app",
  appName: "Kalya Pro",
  webDir: "dist/kalya-pro-web/browser",
  plugins: {
    SplashScreen: {
      /* config native */
    },
  },
};

Plugins Capacitor utilisés

Plugin Usage
@capacitor/app Gestion du cycle de vie et deep links
@capacitor/browser Ouverture du navigateur pour OAuth
@capacitor/splash-screen Écran de chargement natif
@capacitor/status-bar Personnalisation de la barre de status
@capacitor/share Partage natif
@capacitor/dialog Dialogues natifs
@capawesome/capacitor-app-update Mises à jour in-app
cordova-plugin-purchase In-App Purchases
@capacitor-community/advertising-id Tracking publicitaire

Mon pipeline de build mobile

# Synchronisation automatique des versions
npm run version-sync
# → Lit package.json v4.4.3
# → Met à jour android/app/build.gradle (versionCode + versionName)
# → Met à jour ios/App/App.xcodeproj (CFBundleVersion + CFBundleShortVersionString)

# Build complet
npm run capacitor:sync
# → version-sync → ng build (production) → npx cap sync

J'ai développé le script version-sync.js pour automatiser la synchronisation des numéros de version entre le package.json et les configurations natives Android/iOS — une source d'erreurs fréquente que j'ai voulu éliminer.

Ma gestion du deep linking natif

// Écoute des deep links natifs (OAuth callback)
App.addListener("appUrlOpen", (event: URLOpenListenerEvent) => {
  this.zone.run(() => {
    const slug = event.url.split(".com").pop();
    this.initQueryParams(event.url.split("?")[1]);

    if (this.urlParams.has("code")) {
      // Fermeture du navigateur natif + échange de code
      Browser.removeAllListeners();
      Browser.close();
      this.requestToken();
    }

    if (slug) {
      this.router.navigateByUrl(slug);
    }
  });
});

12. 📊 Observabilité & Analytics

J'ai intégré 4 outils de monitoring complémentaires pour couvrir l'ensemble du cycle de vie :

graph LR
    subgraph "Erreurs"
        SE["🛡️ Sentry<br>Frontend + Backend"]
    end

    subgraph "Analytics"
        MA["📊 Matomo<br>RGPD-compliant"]
        GTM["📈 Google Tag Manager"]
    end

    subgraph "Custom"
        XT["📡 X-Headers Tracking<br>Route + Resource + Platform"]
    end

    APP["📱 App"] --> SE
    APP --> MA
    APP --> GTM
    APP --> XT
    XT --> API["⚙️ API"]

Mon tracking custom via X-Headers

Chaque requête API embarque automatiquement des métadonnées de contexte — un système que j'ai conçu pour comprendre finement comment les utilisateurs naviguent :

X-Route: home                      # Page Angular courante
X-Resource-Type: health_problem     # Type de ressource consultée
X-Resource-Id: 42                   # ID spécifique
X-Url: /content/health-problem/42   # URL complète
X-Platform-Origin: ios              # Plateforme (web/ios/android)

→ L'API peut ainsi construire des heatmaps de navigation, analyser les parcours utilisateurs et mesurer l'engagement par contenu, le tout sans cookie tiers.

Liaison Matomo-User

private linkUserMatomo() {
  this.matomoTracker.getVisitorId().then((visitorId) => {
    this.requestApi('/me/tracking/matomo', 'POST', { 'matomo_id': visitorId });
  });
}

Le Matomo visitor ID est lié au profil utilisateur côté serveur, permettant de corréler les sessions analytics avec les comptes utilisateurs de manière RGPD-compliant.


Chiffres clés du projet

Métrique Valeur
Durée de développement 4 ans
Repositories 3 (App, API, SSO)
Version courante v4.4.3
Modèles Eloquent 78
Migrations DB 77
Services Angular 13
Modèles TypeScript 35
Controllers API 18
Dépendances frontend 37
Plateformes cibles 3 (Web, iOS, Android)
Langues supportées 2 (FR, EN)