> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wrappy.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Sondages d'invitation

> Comment fonctionne un formulaire de sondage dans une invitation : une source de vérité unique dont dérivent la validation client, la validation serveur, le type stocké et le rendu du studio.

## Le principe

Un sondage, c'est le formulaire que remplit un invité dans une scène (RSVP, choix de créneaux,
préférences…). Chaque sondage a **un seul fichier de vérité** : un module qui exporte un schéma Zod
et les listes d'options des champs à choix.

Tout le reste en dérive, rien n'est réécrit à la main :

| Ce qui dérive                               | Comment                                         |
| ------------------------------------------- | ----------------------------------------------- |
| Le type TypeScript des réponses             | `z.infer<typeof …ResponsesSchema>`              |
| La validation dans le formulaire            | le schéma passé en validateur (Standard Schema) |
| La validation serveur                       | `zValidator("json", …SubmissionSchema)`         |
| Le type de la colonne `jsonb`               | `jsonb("responses").$type<…Responses>()`        |
| Les libellés, options et agrégats du studio | les listes d'options exportées par le module    |

Le sondage actuel vit dans `packages/core/src/surveys.ts` — c'est le modèle à suivre.

<Warning>
  Aucun champ de sondage ne doit être écrit en dur en dehors de son module. Un champ ajouté à un
  formulaire mais absent du schéma serveur est **silencieusement supprimé** par Zod (`strip` par
  défaut) : le `200` revient, et rien n'arrive en base.
</Warning>

## L'anatomie d'un module de sondage

Trois schémas, dérivés l'un de l'autre, plus deux constantes et une fonction.

```ts theme={null}
// 1. Les listes d'options : la seule définition des choix possibles.
//    L'ordre du tableau est l'ordre d'affichage partout.
export const CHOIX = [{ id: "a", label: "Option A" }] as const;

// 2. La forme STOCKÉE : ce qui part en base dans la colonne jsonb.
//    Le sceneId est stocké pour pouvoir discriminer les scènes plus tard.
export const responsesSchema = z.object({
	sceneId: z.enum(SCENE_IDS),
	choix: z.enum(CHOIX.map((c) => c.id)),
	// …les champs du sondage, avec leurs messages d'erreur en français
});

// 3. Le corps de requête : l'identité du répondant + ses réponses.
export const submissionSchema = z.object({
	respondentName: z.string().trim().min(1, "Le nom est requis.").max(200),
	responses: responsesSchema,
});

// 4. Les valeurs du FORMULAIRE : la forme stockée à plat, sans sceneId.
export const formSchema = responsesSchema
	.omit({ sceneId: true })
	.extend({ respondentName: submissionSchema.shape.respondentName });

export const FORM_DEFAULTS: FormValues = { … };

// 5. Le seul point de conversion « valeurs du formulaire » → « corps de requête ».
export function toSubmission(sceneId: SceneId, { respondentName, ...responses }: FormValues) {
	return { respondentName, responses: { sceneId, ...responses } };
}
```

Pourquoi trois schémas et pas un : les valeurs du formulaire sont à plat (un champ = une entrée) et
n'ont pas de `sceneId` — c'est la scène qui le connaît, pas l'invité. Les dériver par
`.omit()`/`.extend()` garantit qu'ils ne peuvent pas diverger.

Les messages d'erreur vivent **dans le schéma**, pas dans les formulaires : le client et le serveur
disent alors exactement la même chose.

## Le chemin d'une réponse

<Steps>
  <Step title="Le formulaire dans la scène">
    Une scène affiche un formulaire dont les champs à choix sont rendus **en itérant sur les listes
    d'options** du module. Ajouter une option ne touche donc jamais au rendu.
  </Step>

  <Step title="Validation client">
    Le `formSchema` sert de validateur — mêmes règles et mêmes messages que le serveur.
  </Step>

  <Step title="Mapping">
    `toSubmission(sceneId, values)` construit le corps de requête. C'est le seul endroit où le
    `sceneId` est injecté.
  </Step>

  <Step title="Remontée à l'app hôte">
    La scène appelle `onSurveySubmit(data)`, reçu via `SceneProps`. Une scène ne connaît ni le token
    de l'invitation ni l'API : c'est l'app hôte qui branche la mutation.
  </Step>

  <Step title="Appel HTTP typé">
    Hono RPC : `client.invitations[":token"].survey.$post({ json: data })`. Le corps est typé par le
    schéma Zod du serveur — une divergence est une erreur de compilation, pas un bug runtime.
  </Step>

  <Step title="Validation serveur">
    `POST /invitations/:token/survey` (`apps/api/src/modules/invitation/`). `zValidator` rejette en
    `400` avec le détail Zod. La route vérifie que le token existe et que l'expérience est bien de
    type `invitation`, puis hache l'IP (jamais stockée en clair).
  </Step>

  <Step title="Persistance">
    Table `invitation_responses` : une ligne par réponse, les champs du sondage dans la colonne
    `jsonb` `responses`. **Aucune contrainte SQL sur le contenu du JSON — la validation Zod est la
    seule garde.**
  </Step>

  <Step title="Lecture et rendu">
    `GET /invitations/:id/results` (ownership vérifié) alimente la page de résultats du studio.
    Les types viennent de l'inférence du RPC, jamais d'interfaces réécrites à la main.
  </Step>
</Steps>

## Créer un sondage

<Steps>
  <Step title="Le module">
    `packages/core/src/surveys/<nom>.ts` (ou `surveys.ts` s'il n'y en a qu'un), sur le modèle de
    l'anatomie ci-dessus. Déclare l'export dans `packages/core/package.json` — dans `exports` (pour
    les apps) **et** dans `imports` (pour les scènes, via `#/…`).
  </Step>

  <Step title="Le schéma serveur">
    Dans `invitation.routes.ts`, valide avec le `submissionSchema` du module. Si plusieurs sondages
    coexistent, réunis-les :

    ```ts theme={null}
    const responsesSchema = z.discriminatedUnion("sceneId", [premier, second]);
    ```

    C'est le `sceneId` qui discrimine — c'est pour ça qu'il est stocké dans le JSON.
  </Step>

  <Step title="La colonne jsonb">
    Élargis le `$type<>()` de `responses` dans le schéma Drizzle au type (ou à l'union de types) du
    sondage.
  </Step>

  <Step title="Le formulaire">
    Dans la scène. Champs rendus en itérant sur les listes d'options, `FORM_DEFAULTS` en valeurs
    initiales, `formSchema` en validateur, `toSubmission()` puis `onSurveySubmit()` à l'envoi. Ne
    reconstruis jamais le corps de requête à la main dans un composant.
  </Step>

  <Step title="Le rendu des résultats">
    Dans la page de résultats du studio : agrégats et libellés dérivés des listes d'options. Si
    plusieurs sondages coexistent, il faut **narrower sur `sceneId`** avant de lire un champ — les
    membres de l'union n'ont pas les mêmes. Sans ça le typecheck casse, et c'est voulu.
  </Step>
</Steps>

### Le formulaire, concrètement

TanStack Form est obligatoire dans les apps clientes (voir les conventions du projet) : le schéma se
branche directement en validateur, sans adaptateur.

```tsx theme={null}
const form = useForm({
	defaultValues: FORM_DEFAULTS,
	// onChange (et pas seulement onBlur) : voir « Pièges » plus bas
	validators: { onChange: formSchema, onSubmit: formSchema },
	onSubmit: async ({ value }) => {
		await onSurveySubmit(toSubmission("<slug-de-la-scène>", value));
	},
});
```

Les erreurs d'un champ sont des *issues* Zod, pas des chaînes — on affiche
`field.state.meta.errors[0]?.message`.

Pour un champ à choix multiples ou une liste de valeurs, le rendu itère sur la liste d'options du
module (jamais sur des valeurs recopiées dans le composant), et un champ tableau se manipule avec
`mode="array"` + `pushValue()` / `removeValue()` plutôt qu'avec un état local parallèle.

Un environnement qui ne peut pas utiliser TanStack Form (rendu hors DOM, contexte contraint) valide
avec le même schéma en direct :

```ts theme={null}
const parsed = formSchema.safeParse(values);
if (!parsed.success) return setError(parsed.error.issues[0]?.message);
await onSurveySubmit(toSubmission("<slug-de-la-scène>", parsed.data));
```

Dans les deux cas c'est le même schéma qui décide, et les mêmes messages qui s'affichent.

## Modifier un sondage existant

### Ajouter, retirer ou renommer une option

Une ligne dans la liste d'options concernée. Les formulaires, la validation serveur et le studio
suivent — rien d'autre à toucher.

* `id` : stocké en base. Ne le change plus une fois des réponses collectées.
* `label` : affiché dans les formulaires et les résultats.
* Les libellés courts éventuels servent aux affichages contraints (colonnes de tableau).

### Ajouter un champ

1. Ajoute-le au `responsesSchema`, avec son message d'erreur en français.
2. Ajoute sa valeur initiale dans `FORM_DEFAULTS` (TypeScript te le réclamera).
3. Rends-le dans les formulaires de la ou des scènes concernées.
4. Affiche-le dans les résultats si l'organisateur doit le voir.

Les étapes 1 et 2 sont vérifiées par le compilateur. Les 3 et 4 non — c'est du JSX.

<Note>
  Un champ requis ajouté au schéma mais absent d'un formulaire rend ce formulaire impossible à
  soumettre. Rends-le, ou donne-lui un `.default()`.
</Note>

### Changer la forme d'un champ

La colonne `jsonb` n'a aucune contrainte : changer la forme des données ne casse aucune requête SQL,
mais les anciennes lignes restent dans l'ancienne forme et le type TypeScript **mentira** à leur
sujet. Deux options honnêtes : migrer les lignes en SQL, ou les supprimer. Ne les laisse pas
traîner.

## Pièges

**Valide sur `onChange`, pas seulement sur `onBlur`.** Après un submit refusé, TanStack Form met
`canSubmit` à `false` et `handleSubmit` refuse de re-tourner tant que les erreurs ne sont pas
effacées. Avec `onBlur` seul, un champ qui ne déclenche jamais de blur (groupe de cases à cocher,
boutons radio custom) laisse l'utilisateur définitivement bloqué.

**Les erreurs sont des objets.** Avec un validateur Standard Schema, `field.state.meta.errors`
contient des issues Zod. Rendre `errors[0]` directement plante React — c'est `.message`.

**Le `slug` du registry de scènes est le `sceneId`.** La correspondance se fait par comparaison de
chaînes (`scenes.find((s) => s.slug === payload.sceneId)`), pas par le compilateur.

**Les clés de liste d'un champ tableau.** Quand l'index nomme le sous-champ (`champ[i]`), il est
l'identité de la ligne : la valeur rendue vient de l'état du formulaire, pas du DOM. C'est
l'exception assumée à la règle « jamais l'index en clé », et elle porte un `biome-ignore` documenté.

## Ce qu'un sondage ne fait pas

* **Pas de rendu générique.** Il n'existe volontairement aucun générateur de formulaire à partir du
  schéma. Les formulaires d'invitation sont éditorialisés — phrases intercalées entre les champs,
  habillage propre à la scène. Seules la validation et les listes d'options sont partagées, le rendu
  est écrit à la main.
* **Pas de modification après envoi.** Une réponse est une ligne insérée, jamais mise à jour. Un
  invité qui répond deux fois crée deux lignes.
* **Pas d'email du répondant, par défaut.** Aucun sondage ne le demande aujourd'hui : le champ est
  optionnel dans le schéma de soumission et la colonne `respondent_email` est nullable. Un sondage
  qui en a besoin l'ajoute à son formulaire, sans migration. Attention à ne pas le confondre avec
  `experiences.recipientEmails`, qui est la liste de diffusion de l'invitation.
