Skip to main content

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 : Le sondage actuel vit dans packages/core/src/surveys.ts — c’est le modèle à suivre.
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.

L’anatomie d’un module de sondage

Trois schémas, dérivés l’un de l’autre, plus deux constantes et une fonction.
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

1

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

Validation client

Le formSchema sert de validateur — mêmes règles et mêmes messages que le serveur.
3

Mapping

toSubmission(sceneId, values) construit le corps de requête. C’est le seul endroit où le sceneId est injecté.
4

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

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

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

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

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.

Créer un sondage

1

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 #/…).
2

Le schéma serveur

Dans invitation.routes.ts, valide avec le submissionSchema du module. Si plusieurs sondages coexistent, réunis-les :
C’est le sceneId qui discrimine — c’est pour ça qu’il est stocké dans le JSON.
3

La colonne jsonb

Élargis le $type<>() de responses dans le schéma Drizzle au type (ou à l’union de types) du sondage.
4

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

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.

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.
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 :
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.
Un champ requis ajouté au schéma mais absent d’un formulaire rend ce formulaire impossible à soumettre. Rends-le, ou donne-lui un .default().

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.