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.
L’anatomie d’un module de sondage
Trois schémas, dérivés l’un de l’autre, plus deux constantes et une fonction.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 C’est le
invitation.routes.ts, valide avec le submissionSchema du module. Si plusieurs sondages
coexistent, réunis-les :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.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 :
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
- Ajoute-le au
responsesSchema, avec son message d’erreur en français. - Ajoute sa valeur initiale dans
FORM_DEFAULTS(TypeScript te le réclamera). - Rends-le dans les formulaires de la ou des scènes concernées.
- Affiche-le dans les résultats si l’organisateur doit le voir.
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 colonnejsonb 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 suronChange, 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_emailest nullable. Un sondage qui en a besoin l’ajoute à son formulaire, sans migration. Attention à ne pas le confondre avecexperiences.recipientEmails, qui est la liste de diffusion de l’invitation.