Proposer une question
Permettre aux joueurs de proposer de nouvelles questions, avec un formulaire validé dans l'application et dans la base de données.
Objectifs de la séance
- créer une table
proposition_questionprotégée par la RLS, avec des droits d'insertion limités à certaines colonnes ; - laisser la base remplir elle-même
joueur_id,statutetcree_le(valeurs par défaut) ; - écrire une validation partagée entre l'application et la base (tuple TypeScript,
Set) ; - construire un formulaire à plusieurs champs avec un statut d'envoi explicite et un verrou anti double envoi ;
- comprendre le principe de modération d'un contenu écrit par des utilisateurs, puis clore la série.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- citer les trois propriétés d'accessibilité ajoutées au composant
BoutonReponse - citer le fichier qui contient la fonction
vibrerSelonResultatet la valeur dePlatform.OSpour laquelle elle ne fait rien - dans
package.json, lire la version deexpo-haptics
📌 Réponse attendue
TP pour réfléchir et résoudre des problèmes
Votre mission : ajouter un écran Proposer une question. Le joueur choisit un thème et un niveau, écrit
l'énoncé, la bonne réponse et trois mauvaises réponses, puis envoie sa proposition. Elle est stockée dans une nouvelle
table, à part des vraies questions : un enseignant la relit avant de l'ajouter au jeu. Vous êtes sur la branche
feature/extensions.
Étape 1 — Créer la table des propositions
Créez le fichier sql/10_proposition_question.sql. Commencez par l'en-tête et le début de la table :
-- =====================================================================
-- 10_proposition_question.sql : les joueurs proposent de nouvelles questions
-- =====================================================================
-- Les propositions sont relues par l'enseignant (tableau de bord Supabase)
-- avant d'être éventuellement ajoutées aux vraies questions.
create table public.proposition_question (
id bigint generated by default as identity primary key,
La colonne joueur_id désigne l'auteur de la proposition. Le joueur ne doit pas pouvoir l'écrire lui-même
(il pourrait se faire passer pour un autre) : la base la remplit avec auth.uid(), l'identifiant du joueur connecté. Complétez la ligne :
Si l'application envoyait elle-même joueur_id, un joueur malveillant pourrait y mettre l'identifiant d'un autre. Avec
default auth.uid(), c'est le serveur qui sait qui est connecté (grâce au jeton de la session) : l'identité n'est pas négociable.
Étape 2 — Ajouter les colonnes et leurs contraintes
Complétez la table avec les autres colonnes, chacune protégée par une contrainte check. Les mêmes limites seront vérifiées dans
l'application (étape 5), pour que le joueur reçoive un message immédiat :
niveau smallint not null check (niveau between 1 and 3),
enonce text not null check (char_length(enonce) between 10 and 300),
bonne_reponse text not null check (char_length(bonne_reponse) between 1 and 200),
mauvaise_reponse_1 text not null check (char_length(mauvaise_reponse_1) between 1 and 200),
mauvaise_reponse_2 text not null check (char_length(mauvaise_reponse_2) between 1 and 200),
mauvaise_reponse_3 text not null check (char_length(mauvaise_reponse_3) between 1 and 200),
Complétez la ligne qui limite la longueur de l'énoncé de la question (entre 10 et 300 caractères) :
Terminez la table avec le statut de la proposition. Il vaut 'en_attente' à la création, et seul un enseignant (depuis le
tableau de bord) pourra le passer à 'acceptee' ou 'refusee'. Complétez la valeur par défaut :
Les check de la base ne peuvent pas être contournés : même un client qui n'utilise pas votre application
(un curl, un script) ne peut pas enregistrer un énoncé de 10 000 caractères. La validation dans l'application améliore seulement le confort.
Étape 3 — Activer la RLS et limiter les droits aux colonnes de contenu
Ajoutez la suite du script. D'abord, on active la RLS, puis on retire tous les droits aux deux rôles de l'API :
Puis on donne seulement le droit d'insérer dans les colonnes de contenu : joueur_id, statut et
cree_le en sont exclus, ils prendront donc obligatoirement leur valeur par défaut. Complétez le mot-clé du droit :
Ce droit par colonne règle un problème précis : sans lui, un joueur pourrait envoyer {"statut": "acceptee"} et
valider lui-même sa proposition. Avec lui, la base répond « permission denied » dès qu'une colonne non autorisée
est mentionnée dans l'insertion.
Étape 4 — Écrire les règles (policies) et exécuter le script
Deux règles suffisent : un joueur insère une ligne seulement en son nom, et il ne lit que ses propres propositions. Complétez la condition de la règle d'insertion :
Collez tout le script dans Supabase > SQL Editor et cliquez sur Run. Vérifiez ensuite qu'un visiteur anonyme (qui n'a aucun droit sur cette table) est bien refusé, avec la clé publishable de votre projet :
curl -X POST "https://votre-projet.supabase.co/rest/v1/proposition_question" \
-H "apikey: sb_publishable_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"theme_code": "reseaux", "niveau": 1, "enonce": "Question de test pour la RLS", "bonne_reponse": "a", "mauvaise_reponse_1": "b", "mauvaise_reponse_2": "c", "mauvaise_reponse_3": "d"}'
La réponse doit être une erreur permission denied (code 42501) : rien n'a été enregistré.
Une règle de sécurité n'est vraiment vérifiée que lorsqu'on a essayé de la contourner. Le test avec curl prend
dix secondes et prouve qu'un visiteur sans compte n'écrit rien dans votre base.
Étape 5 — Valider la proposition dans l'application
Créez src/logic/validerProposition.ts : une fonction pure (testable sans Supabase) qui reprend les règles de la base et renvoie un
message pour le premier problème trouvé, ou null si tout est correct. Les trois mauvaises réponses forment un
tuple : un tableau de taille fixe ([string, string, string], exactement 3 chaînes). Complétez d'abord le nettoyage de l'énoncé :
Ensuite, on vérifie que les quatre réponses sont différentes (en ignorant les majuscules). Un Set est une collection
qui ne garde qu'un exemplaire de chaque valeur : si sa taille est plus petite que celle du tableau d'origine, il y avait des doublons. Complétez la ligne :
Écrivez aussi le fichier de tests tests/validerProposition.test.ts, puis lancez npm test :
/// <reference types="node" />
import assert from "node:assert/strict";
import { test } from "node:test";
import { type Proposition, validerProposition } from "../src/logic/validerProposition.ts";
const valide: Proposition = {
theme: "reseaux",
niveau: 1,
enonce: "Que signifie le sigle Wi-Fi ?",
bonneReponse: "Wireless Fidelity",
mauvaisesReponses: ["Wide Fiber", "Web Filter", "Wired File"],
};
test("une proposition complète est valide", () => {
assert.equal(validerProposition(valide), null);
});
test("un énoncé trop court est refusé", () => {
assert.notEqual(validerProposition({ ...valide, enonce: "Wi-Fi ?" }), null);
});
test("une réponse vide est refusée", () => {
assert.notEqual(validerProposition({ ...valide, mauvaisesReponses: ["a", " ", "c"] }), null);
});
test("deux réponses identiques (même en majuscules) sont refusées", () => {
assert.notEqual(
validerProposition({ ...valide, mauvaisesReponses: ["wireless fidelity", "b", "c"] }),
null,
);
});
L'application vérifie pour le confort (le joueur voit tout de suite pourquoi le bouton est grisé). La base vérifie pour la sécurité (rien ne passe, même sans l'application). Si vous changez une limite, changez-la aux deux endroits.
Étape 6 — Envoyer la proposition depuis un service
Créez src/services/propositionService.ts. Remarquez ce que l'insertion n'envoie pas : ni joueur_id, ni statut,
ni cree_le : la base les remplit. Complétez l'appel d'insertion dans la table :
Le service ne connaît ni l'écran ni les composants : il reçoit une Proposition et renvoie une promesse. En cas d'échec il lève
une erreur au message simple (le détail technique est dans la console). L'écran n'a plus qu'à afficher ce message.
Étape 7 — Créer le composant ChampTexte
Le formulaire contient cinq champs de saisie qui se ressemblent : un libellé, une zone de saisie, une longueur maximale. On crée
un composant réutilisable, src/components/ChampTexte.tsx. Complétez la propriété qui empêche de saisir plus de caractères que
la base n'en accepte :
accessibilityLabel={libelle} fait lire « Bonne réponse » ou « Mauvaise réponse 1 » par le lecteur d'écran quand le champ prend le
focus. Un champ sans libellé accessible est un champ muet pour une personne qui ne voit pas l'écran.
Étape 8 — Écrire l'écran du formulaire
Créez src/app/proposer.tsx. L'écran garde un état par champ, et un statut d'envoi explicite
("saisie", "envoi" ou "envoye") à la place de plusieurs booléens qui pourraient se contredire. Complétez la
garde au début de la fonction envoyer : on ne fait rien si la saisie est invalide ou si un envoi est déjà en cours
(verrou useRef, comme pour le pseudo) :
Complétez ensuite la propriété qui désactive le bouton : tant que la saisie est invalide, ou dès que l'envoi a commencé.
Voici l'écran complet (les cinq champs utilisent ChampTexte, les thèmes viennent de useThemes) :
import { useRef, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import BoutonPrincipal from "@/components/BoutonPrincipal";
import ChampTexte from "@/components/ChampTexte";
import ChoixOption from "@/components/ChoixOption";
import MessageErreur from "@/components/MessageErreur";
import { COULEURS } from "@/constants/couleurs";
import { useThemes } from "@/hooks/useThemes";
import { type Proposition, validerProposition } from "@/logic/validerProposition";
import { envoyerProposition } from "@/services/propositionService";
import type { CodeTheme, Niveau } from "@/types/quiz";
// Statut explicite du formulaire
type StatutEnvoi = "saisie" | "envoi" | "envoye";
const NIVEAUX: Niveau[] = [1, 2, 3];
// Écran "Proposer une question" (route "/proposer")
export default function EcranProposer() {
const { etatThemes } = useThemes();
const [theme, setTheme] = useState<CodeTheme>("mots-de-passe");
const [niveau, setNiveau] = useState<Niveau>(1);
const [enonce, setEnonce] = useState("");
const [bonneReponse, setBonneReponse] = useState("");
const [mauvaise1, setMauvaise1] = useState("");
const [mauvaise2, setMauvaise2] = useState("");
const [mauvaise3, setMauvaise3] = useState("");
const [statut, setStatut] = useState<StatutEnvoi>("saisie");
const [erreurServeur, setErreurServeur] = useState<string | null>(null);
const envoiEnCours = useRef(false); // verrou anti double-envoi
const proposition: Proposition = {
theme,
niveau,
enonce,
bonneReponse,
mauvaisesReponses: [mauvaise1, mauvaise2, mauvaise3],
};
const erreurSaisie = validerProposition(proposition);
async function envoyer() {
if (erreurSaisie !== null || envoiEnCours.current) {
return;
}
envoiEnCours.current = true;
setStatut("envoi");
setErreurServeur(null);
try {
await envoyerProposition(proposition);
setStatut("envoye");
} catch (erreur) {
setErreurServeur(erreur instanceof Error ? erreur.message : "Erreur inconnue.");
setStatut("saisie");
} finally {
envoiEnCours.current = false;
}
}
function nouvelleProposition() {
setEnonce("");
setBonneReponse("");
setMauvaise1("");
setMauvaise2("");
setMauvaise3("");
setStatut("saisie");
}
if (statut === "envoye") {
return (
<SafeAreaView style={styles.ecran} edges={["bottom"]}>
<View style={styles.contenu}>
<Text style={styles.merci}>
{"Merci ! Votre question sera relue avant d'être ajoutée."}
</Text>
<BoutonPrincipal texte="Proposer une autre question" onPress={nouvelleProposition} />
</View>
</SafeAreaView>
);
}
const modifiable = statut === "saisie";
return (
<SafeAreaView style={styles.ecran} edges={["bottom"]}>
<ScrollView contentContainerStyle={styles.contenu} keyboardShouldPersistTaps="handled">
<Text style={styles.titreSection}>Thème</Text>
<View style={styles.ligne}>
{etatThemes.statut === "succes" &&
etatThemes.donnees.map((t) => (
<ChoixOption
key={t.code}
libelle={t.libelle}
estSelectionne={theme === t.code}
onPress={() => setTheme(t.code)}
desactive={!modifiable}
/>
))}
</View>
<Text style={styles.titreSection}>Niveau</Text>
<View style={styles.ligne}>
{NIVEAUX.map((n) => (
<ChoixOption
key={n}
libelle={String(n)}
estSelectionne={niveau === n}
onPress={() => setNiveau(n)}
desactive={!modifiable}
/>
))}
</View>
<ChampTexte
libelle="Question"
valeur={enonce}
onChange={setEnonce}
longueurMax={300}
modifiable={modifiable}
multiligne
/>
<ChampTexte
libelle="Bonne réponse"
valeur={bonneReponse}
onChange={setBonneReponse}
longueurMax={200}
modifiable={modifiable}
/>
<ChampTexte
libelle="Mauvaise réponse 1"
valeur={mauvaise1}
onChange={setMauvaise1}
longueurMax={200}
modifiable={modifiable}
/>
<ChampTexte
libelle="Mauvaise réponse 2"
valeur={mauvaise2}
onChange={setMauvaise2}
longueurMax={200}
modifiable={modifiable}
/>
<ChampTexte
libelle="Mauvaise réponse 3"
valeur={mauvaise3}
onChange={setMauvaise3}
longueurMax={200}
modifiable={modifiable}
/>
{erreurSaisie !== null && enonce.length > 0 && (
<Text style={styles.aide}>{erreurSaisie}</Text>
)}
{erreurServeur !== null && <MessageErreur message={erreurServeur} />}
<BoutonPrincipal
texte={statut === "envoi" ? "Envoi..." : "Envoyer la proposition"}
onPress={envoyer}
desactive={erreurSaisie !== null || statut !== "saisie"}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
ecran: {
flex: 1,
backgroundColor: COULEURS.fond,
},
contenu: {
padding: 16,
gap: 12,
width: "100%",
maxWidth: 600,
alignSelf: "center",
},
titreSection: {
fontSize: 18,
fontWeight: "bold",
color: COULEURS.texte,
},
ligne: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
aide: {
fontSize: 14,
color: COULEURS.erreur,
},
merci: {
fontSize: 18,
color: COULEURS.succes,
textAlign: "center",
},
});
Enfin, reliez l'écran à l'application. Dans src/app/_layout.tsx, déclarez la route :
Puis, dans src/app/index.tsx, ajoutez le bouton juste après « Classement » :
<BoutonPrincipal
texte="Proposer une question"
onPress={() => router.push("/proposer")}
/>
statut ne peut valoir qu'une valeur à la fois : on ne peut pas être « en train d'envoyer » et « envoyé ». Avec
estEnvoi, estEnvoye et aEchoue, trois booléens pourraient se contredire (les trois à true...), et il faudrait
prévoir chaque combinaison. C'est le même principe que EtatChargement et le reducer de la partie.
Étape 9 — Tester et relire les propositions
Lancez l'application (relancez npx expo start pour régénérer les routes typées) et testez le formulaire, dans l'ordre :
- un énoncé de
Wi-Fi ?: le message « La question doit contenir entre 10 et 300 caractères. » apparaît, le bouton est grisé ; - des réponses identiques (
a,A,b,c) : « Les 4 réponses doivent être différentes. » ; - une proposition complète : cliquez très vite deux fois sur « Envoyer la proposition » : un seul envoi a lieu ;
- le message « Merci ! Votre question sera relue avant d'être ajoutée. » s'affiche ;
- dans Supabase > Table Editor, ouvrez
proposition_question: la ligne existe,joueur_idetcree_lesont remplis sans que l'application les ait envoyés, etstatutvauten_attente.
Un enseignant relit ensuite les propositions. Complétez la requête qui liste celles qui attendent une relecture, de la plus ancienne à la plus récente :
Pour accepter une proposition, l'enseignant la recopie dans les tables question et reponse (depuis l'éditeur SQL,
avec les droits d'administrateur), puis passe son statut à acceptee (ou refusee) dans le Table Editor. Ce travail
manuel est volontaire : aucune ligne écrite par un joueur n'entre dans le jeu sans avoir été relue.
Les propositions vivent dans leur propre table, que le jeu ne lit jamais. Une proposition mal intentionnée (insulte, fausse information, lien dangereux) ne peut donc pas apparaître chez les autres joueurs avant d'avoir été validée.
Étape 10 — Enregistrer, fusionner et clore le projet
Vérifiez les types et les tests, puis enregistrez le travail :
npm run verifier
C'est la dernière modification de la série : sur GitHub, ouvrez la Pull Request de feature/extensions vers main, relisez
les fichiers (onglet Files changed), fusionnez-la, puis récupérez le résultat en local :
Votre branche main contient maintenant tout le projet. Relancez npm run verifier et déroulez une dernière fois
docs/RECETTE.md avant de partager l'adresse de votre application : ajoutez-y deux lignes pour le formulaire (énoncé trop court,
double clic sur « Envoyer »).
📌 Une solution
Notions théoriques
Un formulaire à plusieurs champs
Le formulaire du pseudo n'avait qu'un champ ; celui-ci en a sept (thème, niveau, énoncé, quatre réponses). Le principe reste le même :
- un état par champ (
useState), et l'écran affiche toujours ce que contient l'état (champ « contrôlé ») ; - la validité est calculée à chaque rendu à partir des champs (
validerProposition(proposition)), et non stockée dans un état de plus ; - un statut explicite (
"saisie","envoi","envoye") décrit où l'on en est ; - un verrou
useRefempêche un deuxième envoi, même si deux clics arrivent avant que l'écran ne se redessine.
Tuple et Set
- un tuple TypeScript est un tableau de taille et de types fixes :
[string, string, string]refuse un tableau de 2 ou de 4 chaînes, et le compilateur le vérifie. C'est idéal pour « exactement 3 mauvaises réponses » ; - un
Setest une collection sans doublon :new Set(["a", "b", "a"]).sizevaut2. Comparer sa taille à celle du tableau d'origine détecte très simplement les répétitions.
Droits par colonne, valeurs par défaut et RLS
Trois mécanismes se combinent pour rendre l'insertion sûre, même face à un client malveillant :
| Mécanisme | Ce qu'il garantit |
|---|---|
grant insert (colonnes) | le client ne peut écrire que les colonnes de contenu ; toute autre colonne mentionnée provoque permission denied |
default auth.uid(), default 'en_attente', default now() | les colonnes sensibles prennent obligatoirement la valeur choisie par la base |
policy ... with check (auth.uid() = joueur_id) | même si joueur_id était fourni, il devrait être celui du joueur connecté |
policy ... using (auth.uid() = joueur_id) (lecture) | un joueur ne voit que ses propositions |
La logique est celle de tout le projet : le serveur décide, le client demande.
Modérer un contenu écrit par des utilisateurs
Dès qu'un utilisateur peut écrire du contenu qui sera lu par d'autres, il faut prévoir :
- une relecture avant publication (ici, le statut et la table séparée) ;
- des limites (longueurs, nombre de champs) pour éviter les abus et les données énormes ;
- un traitement sûr à l'affichage : dans une application React Native, un texte affiché avec
<Text>n'est jamais interprété comme du code ; sur un site Web, l'affichage de contenu utilisateur est l'une des sources classiques de failles (injection de scripts, XSS) ; - une protection contre le spam : notre version n'en a pas (un joueur peut envoyer autant de propositions qu'il veut). Une limite par joueur et par minute,
comme celle de
demarrer_partie, serait une première amélioration.
Test de mémorisation/compréhension
Exercices complémentaires
Exercice A : compter les caractères
Ajoutez, sous le champ « Question », un texte 12 / 300 qui affiche le nombre de caractères saisis et le maximum. Aide : enonce.length et un
<Text> dans l'écran (ou dans ChampTexte, avec la prop longueurMax qu'il reçoit déjà).
Exercice B : limiter le nombre de propositions
Écrivez, en SQL, la règle qui empêcherait un joueur d'envoyer plus de 5 propositions par jour. Où la placeriez-vous : dans l'application ou dans la base ? Justifiez votre choix.
📌 Réponse attendue
Exercice C : relire sa propre proposition
Ajoutez un écran « Mes propositions » qui affiche (avec une FlatList) les propositions du joueur connecté et leur statut. Quelle policy
de la table autorise déjà cette lecture ?
📌 Réponse attendue
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
default auth.uid() | La base remplit elle-même l'auteur : le client ne choisit pas son identité. |
grant insert (colonnes) | Le client n'écrit que les colonnes de contenu ; statut et joueur_id lui sont interdits. |
Policies with check / using | with check valide ce qui est écrit, using filtre ce qui est lu. |
Tuple et Set | Taille fixe imposée par TypeScript ; détection simple des doublons. |
Statut d'envoi + verrou useRef | "saisie", "envoi", "envoye" : pas d'états contradictoires, pas de double envoi. |
| Double validation | L'application pour le confort, la base pour la sécurité. |
| Modération | Table séparée et relecture avant publication : un contenu d'utilisateur n'est jamais publié tel quel. |
Bilan de la série
Vous avez construit, séance après séance, une application complète : du premier écran vide à une application publiée, avec une base de données protégée et un serveur qui fait autorité.
| Tutoriel | Ce que vous avez appris |
|---|---|
| 1. Démarrer avec Expo | Créer un projet Expo, comprendre ses fichiers, versionner avec Git et GitHub. |
| 2. TypeScript pour le quiz | Décrire les données avec des types, écrire des fonctions pures et des tests, mélanger avec Fisher-Yates. |
| 3. Composants et styles | JSX, StyleSheet, props, listes avec key, Flexbox, images, branches Git et Pull Requests. |
| 4. État et interactions | useState, état dérivé, useReducer et machine à états, useEffect et nettoyage. |
| 5. Navigation avec Expo Router | Écrans et routes, paramètres d'adresse validés, replace contre push. |
| 6. La base de données Supabase | MCD/MLD, tables et contraintes SQL, RLS en lecture, connexion de l'application. |
| 7. Données asynchrones robustes | Chargement/erreur/succès, AbortController, résultats dans le désordre, Supabase Storage. |
| 8. Joueurs et sécurité | Connexion anonyme, formulaire du pseudo, failles côté client, fonctions RPC, score calculé par le serveur. |
| 9. Le classement | Fonction SQL publique sans donnée personnelle, FlatList, filtres. |
| 10. Qualité et déploiement | Cahier de recette, tests, export Web statique, EAS Hosting, APK avec EAS Build. |
| 11. Aller plus loin | Accessibilité, retour haptique, formulaire de propositions, droits par colonne. |
Trois idées traversent toute la série : ne jamais faire confiance au client (le serveur calcule, la base vérifie), prévoir ce qui peut mal tourner (réseau, double clic, adresse falsifiée) et tester ce que l'on publie.
Aperçu de la prochaine séance
Il n'y a pas de séance suivante : le parcours se termine ici. Voici des pistes pour prolonger votre projet :
- publier sur le Google Play Store : compte développeur Google, profil
productiond'EAS Build (AAB) et EAS Submit ; - mettre à jour sans repasser par les magasins avec EAS Update ;
- ajouter une vraie authentification (e-mail et mot de passe, ou compte Google) : le classement pourrait alors suivre un joueur d'un appareil à l'autre ;
- automatiser les vérifications à chaque
git pushavec GitHub Actions (npm run verifier) ; - tester les écrans (tests de composants, ou tests de bout en bout avec Maestro) ;
- écrire un écran d'administration pour relire les propositions sans passer par le tableau de bord Supabase ;
- traduire l'application (internationalisation) ou ajouter des notifications (« une nouvelle question vous attend »).
Chacune de ces pistes s'appuie sur ce que vous savez déjà faire. Bravo pour le chemin parcouru !