### **2. Contrat JSON de Parcours et Modèle de Données**

Ce chapitre définit le pivot du système. Toute évolution ultérieure des trois briques se juge d'abord à son effet sur ce contrat.

#### **2.1. Le contrat de parcours**

Un parcours est un document JSON autonome : le SDK doit pouvoir l'exécuter sans poser d'autre question au serveur.

```json
{
  "id": "tr_7f3a9c",
  "version": 4,
  "name": "Onboarding nouveaux utilisateurs",
  "trigger": {
    "urlPattern": "/dashboard*",
    "frequency": "once"
  },
  "audience": {
    "locales": ["fr", "en"],
    "match": { "role": ["admin", "owner"] }
  },
  "steps": [
    {
      "id": "st_intro",
      "type": "modal",
      "title": "Bienvenue sur la plateforme",
      "body": "Trois minutes pour créer votre premier projet.",
      "advance": "button"
    },
    {
      "id": "st_create",
      "type": "tooltip",
      "title": "Créez un projet",
      "body": "Cliquez ici pour démarrer.",
      "target": {
        "candidates": [
          { "kind": "testid", "value": "create-project" },
          { "kind": "id", "value": "create-project-btn" },
          { "kind": "aria", "role": "button", "name": "Créer un projet" },
          { "kind": "text", "tag": "button", "value": "Créer un projet" }
        ]
      },
      "placement": "bottom",
      "highlight": true,
      "advance": "element-click"
    },
    {
      "id": "st_role",
      "type": "question",
      "title": "Quelle est votre équipe ?",
      "options": [
        { "id": "op_tech", "label": "Technique", "next": "st_api" },
        { "id": "op_sales", "label": "Commerciale", "next": "st_crm" }
      ]
    }
  ]
}
```

**Champs du parcours**

| Champ | Type | Rôle |
| --- | --- | --- |
| `id` | chaîne | Identité stable du parcours, jamais réutilisée |
| `version` | entier | Numéro de version publiée, strictement croissant |
| `trigger.urlPattern` | chaîne | Motif de chemin, avec `*` comme seul joker |
| `trigger.frequency` | `once` \| `always` | Plafonnement d'affichage, évalué localement (section 3.5) |
| `audience` | objet, optionnel | Filtres évalués côté client sur les attributs déclarés par le site hôte |
| `steps` | tableau | Étapes, dans leur ordre par défaut |

**Champs d'une étape**

| Champ | Type | Rôle |
| --- | --- | --- |
| `id` | chaîne | Identité stable, clé de la persistance et des analytics |
| `type` | `tooltip` \| `modal` \| `question` | Voir ci-dessous |
| `title`, `body` | chaîne | Contenu textuel ; `body` accepte un sous-ensemble HTML restreint (section 8.5) |
| `target` | objet | Obligatoire pour `tooltip`, absent sinon |
| `placement` | `top` \| `bottom` \| `left` \| `right` | Position souhaitée, ajustée automatiquement au rendu |
| `highlight` | booléen | Découpe l'élément cible dans le voile d'arrière-plan |
| `advance` | `button` \| `element-click` \| `element-input` | Condition de passage à l'étape suivante |
| `next` | chaîne, optionnel | Étape suivante explicite ; par défaut, la suivante dans le tableau |
| `options` | tableau | Réservé au type `question`, chaque option portant son propre `next` |

**Trois types d'étapes suffisent.** `tooltip` est attachée à un élément, `modal` est centrée et sans cible, `question` est une modale dont les réponses routent. Le « spotlight » du cahier fonctionnel n'est pas un type mais un attribut d'affichage, `highlight` : en faire un type distinct dupliquerait tous les autres champs pour une seule différence visuelle.

**Le branchement est un champ, pas un moteur.** Chaque étape ou option nomme l'étape suivante. Il n'y a ni condition composée, ni expression à évaluer, ni graphe à parcourir : le SDK lit `next`, ou prend l'étape suivante du tableau. Toute règle plus riche devra d'abord exister dans un besoin écrit.

#### **2.2. Le contrat de cible**

C'est le mécanisme qui porte le différenciateur « résilience du ciblage ». Une cible n'est pas un sélecteur, c'est une **liste ordonnée de candidats**, du plus stable au plus fragile.

```json
"target": {
  "candidates": [
    { "kind": "testid", "value": "create-project" },
    { "kind": "id", "value": "create-project-btn" },
    { "kind": "aria", "role": "button", "name": "Créer un projet" },
    { "kind": "text", "tag": "button", "value": "Créer un projet" },
    { "kind": "css", "value": "main > section:nth-of-type(2) > button" }
  ]
}
```

| `kind` | Ce qu'il exprime | Stabilité |
| --- | --- | --- |
| `testid` | Attribut de test posé volontairement par les développeurs du site | La meilleure : il existe pour être ciblé |
| `id` | Identifiant unique de l'élément | Bonne, sauf identifiants générés à la volée |
| `aria` | Rôle d'accessibilité et nom accessible calculé | Bonne : survit aux refontes visuelles |
| `text` | Contenu textuel, restreint à un type de balise | Moyenne : casse à la traduction et aux reformulations |
| `css` | Chemin structurel dans le document | Faible : dernier recours, casse au moindre remaniement |

**Règle de résolution, sans ambiguïté possible :** un candidat n'est retenu que s'il désigne **exactement un** élément présent et visible. Zéro correspondance ou plusieurs correspondances font passer au candidat suivant. Cette règle évite le pire cas de ce type de produit, qui est de désigner avec assurance le mauvais élément.

L'ordre du tableau fait foi et prime sur l'ordre de stabilité théorique du tableau ci-dessus, afin qu'un administrateur puisse corriger un cas particulier depuis l'extension. La capture applique cet ordre par défaut (section 4.3), le SDK l'applique tel qu'il le reçoit (section 3.3).

#### **2.3. Versionnage**

Trois règles, dont découle tout le reste du système de distribution :

1. **L'identité des étapes est stable et définitive.** Un `id` d'étape est attribué à la création et n'est jamais réutilisé, y compris après suppression. C'est ce qui permet de comparer un tunnel de complétion entre deux versions et de reprendre un parcours interrompu.
2. **Une version publiée est immuable.** Modifier un parcours publié est impossible : on modifie le brouillon, puis on publie, ce qui crée une nouvelle version numérotée. Cette immutabilité est ce qui autorise une mise en cache quasi perpétuelle (ADR-03).
3. **Un parcours a au plus une version publiée active.** Le manifeste (section 7.2) désigne, pour chaque parcours actif, le couple identifiant et numéro de version à charger.

Un parcours possède donc toujours deux faces : un brouillon modifiable et, éventuellement, une version publiée figée. L'extension et le dashboard écrivent sur le brouillon ; le SDK ne lit que des versions publiées.

#### **2.4. Schéma PostgreSQL**

Le schéma est piloté par Drizzle, en migrations petites et sans retour arrière, une migration ne mélangeant jamais un changement de structure et une reprise de données.

```sql
CREATE TABLE organizations (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE users (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id  uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email            citext NOT NULL UNIQUE,
  oauth_provider   text NOT NULL,
  oauth_subject    text NOT NULL,
  created_at       timestamptz NOT NULL DEFAULT now(),
  UNIQUE (oauth_provider, oauth_subject)
);

CREATE TABLE projects (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id  uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name             text NOT NULL,
  public_key       text NOT NULL UNIQUE,
  allowed_origins  text[] NOT NULL DEFAULT '{}',
  created_at       timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE tours (
  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id            uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name                  text NOT NULL,
  status                text NOT NULL DEFAULT 'draft'
                        CHECK (status IN ('draft', 'published', 'paused', 'archived')),
  draft_content         jsonb NOT NULL,
  published_version     integer,
  created_at            timestamptz NOT NULL DEFAULT now(),
  updated_at            timestamptz NOT NULL DEFAULT now(),
  CHECK (jsonb_typeof(draft_content -> 'steps') = 'array'),
  CHECK (status <> 'published' OR published_version IS NOT NULL)
);

CREATE TABLE tour_versions (
  tour_id       uuid NOT NULL REFERENCES tours(id) ON DELETE CASCADE,
  version       integer NOT NULL,
  content       jsonb NOT NULL,
  published_at  timestamptz NOT NULL DEFAULT now(),
  published_by  uuid REFERENCES users(id) ON DELETE SET NULL,
  PRIMARY KEY (tour_id, version),
  CHECK (jsonb_typeof(content -> 'steps') = 'array')
);

CREATE TABLE tour_events (
  id            bigserial PRIMARY KEY,
  project_id    uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  tour_id       uuid NOT NULL,
  tour_version  integer NOT NULL,
  step_id       text,
  type          text NOT NULL
                CHECK (type IN ('start', 'step_view', 'choice', 'complete', 'dismiss', 'target_lost')),
  choice_id     text,
  session_id    text NOT NULL,
  occurred_at   timestamptz NOT NULL DEFAULT now()
);
```

**Index, chacun rattaché à un accès nommé :**

| Index | Accès qu'il sert |
| --- | --- |
| `projects(public_key)`, unique | Résolution de la clé publique à chaque requête du SDK |
| `tours(project_id, status)` | Construction du manifeste et liste du dashboard |
| `tour_events(project_id, tour_id, occurred_at)` | Agrégation du tunnel de complétion (section 5.6) |
| `tour_events(session_id)` | Reconstitution d'une session lors d'un diagnostic |

Aucun autre index n'est créé avant qu'un plan d'exécution ne le réclame.

**Choix de modélisation à justifier, conformément à la doctrine :**

- **Clés primaires en UUID** : les identifiants de projet et de parcours circulent dans des URL publiques et dans le manifeste. Un entier séquentiel y révélerait le volume d'affaires et permettrait l'énumération.
- **`tour_events` en `bigserial`** : cette table n'est jamais exposée ni référencée depuis l'extérieur, et son volume justifie la clé la plus compacte.
- **Pas de clé étrangère de `tour_events` vers `tour_versions`** : les événements doivent survivre à l'archivage d'une version, et l'ingestion ne doit pas coûter une vérification référentielle par événement. Le rattachement se fait à la lecture.
- **Deux niveaux, organisation et projet** : une même organisation exploite plusieurs produits, et le cahier fonctionnel prévoit l'édition sur les environnements de développement, de recette et de production. Les origines autorisées étant portées par le projet sous forme de liste, les trois environnements d'un même produit partagent leurs parcours sans duplication.
- **Contraintes `CHECK` sur le JSONB** : le moteur garantit le minimum structurel, à savoir la présence d'un tableau d'étapes. La validation complète du contrat reste applicative, à la publication (section 5.3), car exprimer un schéma JSON complet en contraintes SQL serait illisible et impossible à faire évoluer.

#### **2.5. Modèle multi-locataire**

Le cloisonnement repose sur une règle unique et vérifiable : **toute requête d'administration est filtrée par l'organisation de l'utilisateur authentifié, et toute requête publique est filtrée par le projet résolu depuis la clé publique.** Aucun accès aux données ne part d'un identifiant fourni par le client sans être joint à l'un de ces deux ancrages.

Concrètement, une lecture de parcours par le dashboard ne s'écrit jamais « le parcours d'identifiant X », mais « le parcours d'identifiant X appartenant à un projet de mon organisation ». Un identifiant deviné ou volé ne donne alors aucun accès.

Ce filtrage est appliqué dans la couche d'accès aux données, à un seul endroit par entité, et couvert par un test dédié qui vérifie qu'une organisation ne peut pas lire les parcours d'une autre. C'est le test le plus important du backend : un défaut de cloisonnement est une fuite de données chez des clients qui achètent précisément de la souveraineté.
