Aller au contenu principal

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_question protégée par la RLS, avec des droits d'insertion limités à certaines colonnes ;
  • laisser la base remplir elle-même joueur_id, statut et cree_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...

  1. citer les trois propriétés d'accessibilité ajoutées au composant BoutonReponse
  2. citer le fichier qui contient la fonction vibrerSelonResultat et la valeur de Platform.OS pour laquelle elle ne fait rien
  3. dans package.json, lire la version de expo-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 :


Bonne pratique - Ne jamais faire confiance au client pour dire qui il est

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 :


Bonne pratique - Les contraintes sont la dernière ligne de défense

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 :


Bonne pratique - Interdire par défaut, autoriser colonne par colonne

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


Bonne pratique - Écrire la requête d'attaque avant de dire « c'est sécurisé »

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,
);
});

Bonne pratique - La même règle aux deux endroits, pour deux raisons différentes

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 :


Bonne pratique - Un service, une responsabilité

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 :


Bonne pratique - Réutiliser l'accessibilité de la séance précédente

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")}
/>

Bonne pratique - Un statut plutôt que trois booléens

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 :

  1. un énoncé de Wi-Fi ? : le message « La question doit contenir entre 10 et 300 caractères. » apparaît, le bouton est grisé ;
  2. des réponses identiques (a, A, b, c) : « Les 4 réponses doivent être différentes. » ;
  3. une proposition complète : cliquez très vite deux fois sur « Envoyer la proposition » : un seul envoi a lieu ;
  4. le message « Merci ! Votre question sera relue avant d'être ajoutée. » s'affiche ;
  5. dans Supabase > Table Editor, ouvrez proposition_question : la ligne existe, joueur_id et cree_le sont remplis sans que l'application les ait envoyés, et statut vaut en_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.


Bonne pratique - Séparer ce qui est proposé de ce qui est publié

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 :


Bonne pratique - Terminer proprement

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 useRef empê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 Set est une collection sans doublon : new Set(["a", "b", "a"]).size vaut 2. 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écanismeCe 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​


Pourquoi joueur_id est-il rempli par default auth.uid() plutôt qu'envoyé par l'application ?


Que se passe-t-il si un client envoie {"statut": "acceptee"} dans son insertion ?


À quoi sert un tuple [string, string, string] dans le type Proposition ?


Pourquoi comparer differentes.size à reponses.length avec un Set ?


Pourquoi un statut ("saisie" | "envoi" | "envoye") vaut-il mieux que trois booléens ?


Pourquoi valider la proposition à la fois dans l'application et dans la base ?



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​

NotionRé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 / usingwith check valide ce qui est écrit, using filtre ce qui est lu.
Tuple et SetTaille 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 validationL'application pour le confort, la base pour la sécurité.
ModérationTable 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é.

TutorielCe que vous avez appris
1. Démarrer avec ExpoCréer un projet Expo, comprendre ses fichiers, versionner avec Git et GitHub.
2. TypeScript pour le quizDécrire les données avec des types, écrire des fonctions pures et des tests, mélanger avec Fisher-Yates.
3. Composants et stylesJSX, StyleSheet, props, listes avec key, Flexbox, images, branches Git et Pull Requests.
4. État et interactionsuseState, é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 SupabaseMCD/MLD, tables et contraintes SQL, RLS en lecture, connexion de l'application.
7. Données asynchrones robustesChargement/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 classementFonction SQL publique sans donnée personnelle, FlatList, filtres.
10. Qualité et déploiementCahier de recette, tests, export Web statique, EAS Hosting, APK avec EAS Build.
11. Aller plus loinAccessibilité, 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 production d'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 push avec 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 !