# Plan de Migration SaaS Multi-Tenant

> Application : Unified — Plateforme de gestion d'événements médicaux (Laravel 11)  
> Date : 2026-05-05  
> Objectif : Transformer l'application en SaaS multi-tenant avec isolation des données par tenant

---

## Table des matières

1. [Analyse de l'état actuel](#1-analyse-de-létat-actuel)
2. [Stratégie multi-tenant retenue](#2-stratégie-multi-tenant-retenue)
3. [Phase 1 — Modèle Tenant & infrastructure](#phase-1--modèle-tenant--infrastructure)
4. [Phase 2 — Isolation des données](#phase-2--isolation-des-données)
5. [Phase 3 — Routing & identification du tenant](#phase-3--routing--identification-du-tenant)
6. [Phase 4 — Authentification & permissions par tenant](#phase-4--authentification--permissions-par-tenant)
7. [Phase 5 — Fichiers & stockage](#phase-5--fichiers--stockage)
8. [Phase 6 — Emails & communications](#phase-6--emails--communications)
9. [Phase 7 — Billing & plans](#phase-7--billing--plans)
10. [Phase 8 — Onboarding & super-admin](#phase-8--onboarding--super-admin)
11. [Phase 9 — Tests & observabilité](#phase-9--tests--observabilité)
12. [Phase 10 — Déploiement & scalabilité](#phase-10--déploiement--scalabilité)
13. [Récapitulatif des risques](#récapitulatif-des-risques)

---

## 1. Analyse de l'état actuel

### Ce qui existe déjà (atouts)

| Élément | État | Notes |
|---|---|---|
| Modèle `Client` | ✅ Existe | Champ `is_active`, dates de trial — base solide à étendre |
| Routing subdomain | ✅ Existe | `APP_FRONTEND_SUB_DOMAIN` / `APP_BACKEND_SUB_DOMAIN` dans `config/app.php` |
| RBAC Spatie | ✅ Existe | Roles par guard, à étendre par tenant |
| `event_id` sur toutes les entités | ✅ Existe | Isolation déjà partielle au niveau événement |
| Soft deletes & audit | ✅ Existe | Colonnes `created_by`, `deleted_by` sur la majorité des modèles |

### Ce qui manque (travail à faire)

- Aucune colonne `tenant_id` sur les tables principales
- Base de données unique partagée sans isolation formelle
- Pas de résolution de tenant dans le cycle de vie d'une requête
- Pas de scoping automatique des queries par tenant
- Stockage de fichiers non segmenté par tenant
- Emails envoyés depuis un seul compte sans branding par tenant
- Pas de dashboard super-admin pour gérer les tenants
- Pas de système de plans/limites (quotas d'événements, participants, etc.)

---

## 2. Stratégie multi-tenant retenue

### Approche choisie : **Single Database + tenant_id (Row-Level Isolation)**

**Justification :**
- L'architecture actuelle (event_id sur toutes les tables) se prête naturellement à l'ajout d'un `tenant_id`
- Plus simple à opérer qu'une base par tenant (pas de migrations N fois)
- Moins coûteux à l'infrastructure
- Adapté à un marché PME (événements médicaux en Afrique du Nord)

**Alternative écartée :** Database-per-tenant — viable pour des clients entreprise avec données très sensibles, peut être ajoutée plus tard via le package `stancl/tenancy` en mode multi-DB.

### Package recommandé

```
stancl/tenancy (v3) — https://tenancyforlaravel.com
```

- Supporte Single DB, Multi DB, et hybride
- Intégration native Laravel 11
- Middleware de résolution automatique
- Scoping Eloquent automatique via `BelongsToTenant` trait

---

## Phase 1 — Modèle Tenant & infrastructure

**Durée estimée : 3-5 jours**

### 1.1 Installer stancl/tenancy

```bash
composer require stancl/tenancy
php artisan tenancy:install
```

Fichiers générés :
- `app/Models/Tenant.php`
- `config/tenancy.php`
- `app/Providers/TenancyServiceProvider.php`
- Migration `create_tenants_table`

### 1.2 Étendre le modèle Tenant

Remplacer ou fusionner le modèle `Client` existant avec `Tenant` :

```php
// app/Models/Tenant.php
class Tenant extends BaseTenant implements TenantWithDatabase
{
    use HasDatabase, HasDomains;

    protected $fillable = [
        'id', 'name', 'slug', 'email', 'plan', 
        'is_active', 'trial_ends_at', 'settings',
    ];

    protected $casts = [
        'settings' => 'array',
        'trial_ends_at' => 'datetime',
        'is_active' => 'boolean',
    ];
}
```

### 1.3 Migration du modèle Client → Tenant

```sql
-- Migration : ajouter les colonnes manquantes à la table tenants
-- et créer une FK tenant_id sur les tables clés
ALTER TABLE clients ADD COLUMN slug VARCHAR(100) UNIQUE;
ALTER TABLE clients ADD COLUMN plan ENUM('starter','pro','enterprise') DEFAULT 'starter';
ALTER TABLE clients ADD COLUMN settings JSON;
```

Tables à enrichir avec `tenant_id` (priorité haute) :

| Table | Action |
|---|---|
| `users` | `ADD COLUMN tenant_id BIGINT UNSIGNED NULL` |
| `events` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `participants` | Hérité via `event_id` (pas de colonne directe nécessaire) |
| `hotels` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `templates` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `campaigns` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `forms` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `settings` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `doctors` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |
| `sponsors` | `ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL` |

### 1.4 Créer les index

```sql
CREATE INDEX idx_events_tenant ON events(tenant_id);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_hotels_tenant ON hotels(tenant_id);
-- etc. pour chaque table avec tenant_id
```

---

## Phase 2 — Isolation des données

**Durée estimée : 5-8 jours**

### 2.1 Trait BelongsToTenant

Ajouter le trait Spatie/tenancy sur tous les modèles concernés :

```php
// Modèles prioritaires
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;

class Event extends Model
{
    use BelongsToTenant;
    // ...
}
```

Modèles à traiter (par ordre de criticité) :
1. `Event` — entité centrale
2. `User` — isolation des comptes
3. `Hotel` — hébergements
4. `Template` / `AttestationTemplate` — branding
5. `Campaign` / `SmsCampaign` — communications
6. `Form` — formulaires dynamiques
7. `Setting` — configuration tenant
8. `Doctor` — registre médical
9. `Sponsor` — sponsors
10. `Badge` / `EventBadge` — badges

### 2.2 Global Scope automatique

`stancl/tenancy` injecte automatiquement un `GlobalScope` sur tous les modèles utilisant `BelongsToTenant`. Vérifier que **aucune query** ne bypasse ce scope (grep sur `withoutTenancy()` et `allTenants()`).

### 2.3 Modèles partagés (pas de tenant_id)

Ces modèles restent globaux (partagés entre tous les tenants) :

| Modèle | Raison |
|---|---|
| `Country` | Référentiel géographique global |
| `Gouvernorat` | Référentiel régional |
| `Specialty` | Référentiel médical global |
| `Mode` | Référentiel médical global |
| `Plan` | Définition des plans SaaS |

### 2.4 Données cross-tenant (super-admin)

Créer un `SuperAdminScope` ou utiliser `Tenancy::query()` pour les vues d'administration globale. Ne jamais exposer ces vues aux utilisateurs tenant.

---

## Phase 3 — Routing & identification du tenant

**Durée estimée : 2-3 jours**

### 3.1 Stratégie d'identification

**Option A — Sous-domaine dynamique (recommandé)**

Chaque tenant a son propre sous-domaine :
```
acr2026.mediknode.com  →  tenant "acr2026"
smc2026.mediknode.com  →  tenant "smc2026"
```

**Option B — Chemin URL (fallback)**
```
mediknode.com/acr2026/...
```

### 3.2 Configuration tenancy.php

```php
// config/tenancy.php
'tenant_resolver' => [
    \Stancl\Tenancy\Resolvers\DomainTenantResolver::class,
],

'central_domains' => [
    'mediknode.com',
    'backend.mediknode.com',  // super-admin
    'app.mediknode.com',      // landing/onboarding
],
```

### 3.3 Refactoring des routes

**Avant (état actuel) :**
```php
Route::domain($frontendSubDomain.'.'.$domain)->group(...)
Route::domain($backendSubDomain.'.'.$domain)->group(...)
```

**Après :**
```php
// routes/tenant.php — routes tenant (sous-domaine dynamique)
Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    // toutes les routes frontend et backend tenant
});

// routes/web.php — routes centrales (landing, onboarding, super-admin)
Route::middleware('web')->group(function () {
    // marketing, création de compte tenant, super-admin
});
```

### 3.4 Middleware à créer/adapter

| Middleware | Action |
|---|---|
| `InitializeTenancyByDomain` | Fourni par stancl/tenancy |
| `PreventAccessFromCentralDomains` | Fourni par stancl/tenancy |
| `MaintenanceBySection` | Adapter pour supporter `tenant->settings['maintenance']` |
| `NoIndexBackend` | Conserver tel quel |

---

## Phase 4 — Authentification & permissions par tenant

**Durée estimée : 3-4 jours**

### 4.1 Isolation des utilisateurs

Chaque `User` appartient à un tenant (`tenant_id`). Un même email peut exister chez plusieurs tenants (comptes distincts).

```php
// Modification du LoginController
// Vérifier que l'utilisateur authentifié appartient bien au tenant courant
protected function credentials(Request $request): array
{
    return [
        'email' => $request->email,
        'password' => $request->password,
        'tenant_id' => tenant('id'),  // Scope automatique
    ];
}
```

### 4.2 Roles & permissions scoped par tenant

Spatie/Permission supporte nativement les teams (= tenants) :

```php
// config/permission.php
'teams' => true,
'team_foreign_key' => 'tenant_id',
```

Appeler `setPermissionsTeamId(tenant('id'))` dans le middleware de résolution du tenant.

### 4.3 Super-admin

Créer un guard séparé `superadmin` ou utiliser un rôle global non scopé :

```php
// app/Models/SuperAdmin.php — modèle distinct, sans tenant_id
class SuperAdmin extends Authenticatable
{
    protected $guard = 'superadmin';
}
```

### 4.4 Magic Links par tenant

Le `MagicLinkService` doit inclure le `tenant_id` dans le payload du lien pour s'assurer qu'un lien généré pour tenant A ne fonctionne pas sur tenant B.

---

## Phase 5 — Fichiers & stockage

**Durée estimée : 2-3 jours**

### 5.1 Organisation des disques

Tous les fichiers uploadés doivent être segmentés par tenant :

```
storage/
  tenants/
    {tenant_id}/
      badges/
      attestations/
      vouchers/
      documents/
      templates/
      exports/
```

### 5.2 Adapter les disques Laravel

```php
// config/filesystems.php
'disks' => [
    'tenant' => [
        'driver' => 'local',
        'root' => storage_path('app/tenants/'.tenant('id')),
    ],
    's3_tenant' => [
        'driver' => 's3',
        'bucket' => env('AWS_BUCKET'),
        'root' => 'tenants/'.tenant('id'),
    ],
],
```

### 5.3 Jobs PDF à adapter

Les jobs suivants passent un chemin de stockage en dur — les adapter pour utiliser `tenant('id')` :

- `GenerateAllVouchers`
- `GenerateMassiveAttestations`
- `GeneratePDFAttestationsJob`
- `GenerateParticipantBadgePDF`
- `GenerateMassiveBadgePDF`
- `GenerateDynamicPDFAttestationJob`

Pattern à appliquer dans chaque job :
```php
Storage::disk('tenant')->put($path, $content);
```

---

## Phase 6 — Emails & communications

**Durée estimée : 3-4 jours**

### 6.1 Configuration SMTP par tenant

Chaque tenant peut avoir son propre expéditeur (branding) :

```php
// Stocker dans tenant->settings
{
  "mail": {
    "from_name": "Congrès ACR 2026",
    "from_address": "noreply@acr2026.mediknode.com",
    "smtp_host": "smtp.brevo.com",
    "smtp_username": "...",
    "smtp_password": "..."
  }
}
```

Surcharger la config mail au moment de l'initialisation du tenant :

```php
// TenancyServiceProvider.php — dans le bootstrapper
config([
    'mail.from.name' => tenant('settings.mail.from_name'),
    'mail.from.address' => tenant('settings.mail.from_address'),
]);
```

### 6.2 Queues par tenant

Les jobs asynchrones (envoi de campagnes, génération PDF) doivent connaître le tenant au moment de leur exécution :

```php
// Utiliser le trait fourni par stancl/tenancy
use Stancl\Tenancy\Queues\TenantAwareJob;

class SendCampaignEmail extends Job
{
    use TenantAwareJob;
    // ...
}
```

### 6.3 EmailLog & SystemEmailLog

Ajouter `tenant_id` sur ces tables pour pouvoir filtrer les logs par tenant dans le dashboard.

---

## Phase 7 — Billing & plans

**Durée estimée : 4-6 jours**

### 7.1 Définition des plans

```php
// Table : plans
Schema::create('plans', function (Blueprint $table) {
    $table->id();
    $table->string('name');          // starter, pro, enterprise
    $table->string('slug')->unique();
    $table->integer('max_events');   // -1 = illimité
    $table->integer('max_participants_per_event');
    $table->integer('max_users');
    $table->boolean('has_sms');
    $table->boolean('has_custom_domain');
    $table->boolean('has_api_access');
    $table->decimal('price_monthly', 8, 2);
    $table->decimal('price_yearly', 8, 2);
    $table->timestamps();
});
```

### 7.2 Limites & quotas

Créer un service `TenantQuotaService` :

```php
class TenantQuotaService
{
    public function canCreateEvent(Tenant $tenant): bool;
    public function canAddParticipant(Tenant $tenant, Event $event): bool;
    public function canSendSms(Tenant $tenant): bool;
    public function getRemainingEvents(Tenant $tenant): int;
}
```

Appeler ce service dans les controllers avant toute création de ressource.

### 7.3 Intégration paiement (optionnel phase 1)

- Utiliser **Stripe** (ou **Flouci** si marché MENA) pour les abonnements
- Laravel Cashier pour Stripe : `composer require laravel/cashier`
- Stocker `stripe_customer_id`, `stripe_subscription_id` sur le tenant

### 7.4 Page de dépassement de quota

Middleware `CheckTenantQuota` : si le tenant a dépassé ses limites, rediriger vers une page d'upgrade.

---

## Phase 8 — Onboarding & super-admin

**Durée estimée : 4-5 jours**

### 8.1 Workflow de création de tenant

1. Formulaire sur `mediknode.com/register` (domaine central)
2. Validation email
3. Création du `Tenant` + `User` admin
4. Provisionnement du sous-domaine (DNS wildcard `*.mediknode.com` → déjà configuré)
5. Email de bienvenue avec lien vers `{slug}.mediknode.com/setup`
6. Wizard de configuration initiale (nom organisation, logo, premier événement)

### 8.2 Dashboard super-admin

Routes sur `backend.mediknode.com` (domaine central, pas de tenant) :

| Page | Description |
|---|---|
| `/tenants` | Liste de tous les tenants + statut + plan |
| `/tenants/{id}` | Détail tenant (usage, logs, settings) |
| `/tenants/{id}/impersonate` | Se connecter en tant que ce tenant |
| `/tenants/create` | Créer un tenant manuellement |
| `/plans` | Gestion des plans et tarifs |
| `/metrics` | Métriques globales (tenants actifs, events, participants) |

### 8.3 Impersonation

Permettre au super-admin de se connecter dans le contexte d'un tenant sans connaître le mot de passe :

```php
// Route centrale
Route::post('/tenants/{tenant}/impersonate', [SuperAdminController::class, 'impersonate']);

// Génère un token signé à durée limitée (15 min)
// Redirige vers {tenant.domain}/impersonate?token=...
```

---

## Phase 9 — Tests & observabilité

**Durée estimée : 3-4 jours**

### 9.1 Tests d'isolation

Écrire des tests vérifiant qu'un tenant ne peut pas accéder aux données d'un autre :

```php
// tests/Feature/TenantIsolationTest.php
it('tenant A cannot see tenant B events', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();
    
    $event = Event::factory()->for($tenantA)->create();
    
    tenancy()->initialize($tenantB);
    
    expect(Event::find($event->id))->toBeNull();
});
```

### 9.2 Logging par tenant

Ajouter le `tenant_id` dans tous les logs applicatifs :

```php
// TenancyServiceProvider — bootstrapper
Log::withContext(['tenant_id' => tenant('id')]);
```

### 9.3 Métriques

Exposer des métriques par tenant dans le super-admin :
- Nombre d'événements actifs
- Participants inscrits ce mois
- Emails envoyés
- Stockage utilisé

---

## Phase 10 — Déploiement & scalabilité

**Durée estimée : 2-3 jours**

### 10.1 DNS

Configurer un wildcard DNS :
```
*.mediknode.com  →  A  <IP_SERVEUR>
```

### 10.2 HTTPS wildcard

Certificat wildcard `*.mediknode.com` via Let's Encrypt (Certbot) ou Cloudflare.

### 10.3 Configuration Nginx/Caddy

```nginx
server {
    listen 443 ssl;
    server_name *.mediknode.com;
    ssl_certificate /etc/letsencrypt/live/mediknode.com/fullchain.pem;
    # ...
    location / {
        proxy_pass http://laravel_app;
        proxy_set_header Host $host;
    }
}
```

### 10.4 Variables d'environnement

```env
# Domaine central
APP_DOMAIN=mediknode.com
APP_CENTRAL_DOMAINS=mediknode.com,backend.mediknode.com

# Base de données unique
DB_CONNECTION=mysql
DB_DATABASE=unified_saas

# Queue (Redis recommandé en production)
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1

# Storage
FILESYSTEM_DISK=s3  # ou local selon budget
```

### 10.5 Scalabilité future

Si un tenant enterprise nécessite isolation complète :
- Activer le mode `multi-database` de stancl/tenancy
- Créer une DB dédiée à la volée lors du provisionnement
- Les migrations sont jouées automatiquement par tenant

---

## Récapitulatif des risques

| Risque | Probabilité | Impact | Mitigation |
|---|---|---|---|
| Fuite de données cross-tenant (oubli de BelongsToTenant sur un modèle) | Moyen | Critique | Tests d'isolation automatisés, revue systématique des modèles |
| Performance (GlobalScope sur toutes les queries) | Faible | Moyen | Index `tenant_id` sur toutes les tables, monitoring des slow queries |
| Migration des données existantes vers tenant_id | Élevé | Élevé | Script de migration avec dry-run, backup avant exécution |
| Jobs asynchrones sans contexte tenant | Moyen | Élevé | Trait `TenantAwareJob` obligatoire sur tous les jobs |
| Stockage de fichiers non migré | Faible | Moyen | Script de déplacement avec vérification intégrité |
| Certificat wildcard SSL | Faible | Élevé | Renouvellement automatique Certbot / Cloudflare |

---

## Ordre d'exécution recommandé

```
Phase 1  (Tenant model)          ████████░░░░░░░░░░░░  ~1 semaine
Phase 3  (Routing)               ████████░░░░░░░░░░░░  ~1 semaine  ← en parallèle de Phase 2
Phase 2  (Isolation données)     ████████████░░░░░░░░  ~2 semaines
Phase 4  (Auth/Permissions)      ████████░░░░░░░░░░░░  ~1 semaine
Phase 5  (Fichiers)              ██████░░░░░░░░░░░░░░  ~3-4 jours
Phase 6  (Emails)                ██████░░░░░░░░░░░░░░  ~3-4 jours
Phase 8  (Onboarding/Admin)      ████████░░░░░░░░░░░░  ~1 semaine
Phase 9  (Tests)                 ██████░░░░░░░░░░░░░░  ~en continu
Phase 7  (Billing)               ████████░░░░░░░░░░░░  ~1 semaine  ← peut attendre v2
Phase 10 (Déploiement)           ████░░░░░░░░░░░░░░░░  ~2-3 jours  ← en fin de projet
```

**Durée totale estimée : 6-8 semaines** (1 développeur full-stack senior)

---

*Plan généré le 2026-05-05 — à réviser après audit de performance et choix définitif de l'hébergeur.*
