Aller au contenu

Portail de soumissions

Extension payante. Une vue publique devient un portail : liste privée, recherchable, filtrable et paginée, avec export ciblé, workflow de publication et liens privés expirants. Elle vise les annuaires, les demandes d’adhésion, les inscriptions, les candidatures et les dossiers clients.

Ce document décrit ce que le module garantit et ce qu’il refuse. Le guide utilisateur en donne la version courte, dans la section « Publier une liste de soumissions ».

Une vue existante ne change pas

Le portail s’allume par un réglage. Tant qu’il est éteint — et il l’est pour toutes les vues déjà publiées — la vue se rend exactement comme avant : même requête, même balisage, mêmes protections.

Aucune migration n’a été nécessaire pour cela. Écrire "portal":{"enabled":false} dans chaque vue aurait réécrit toutes les métadonnées du site pour aboutir au comportement que l’absence du réglage produit déjà.

Publier n’est pas un statut de soumission

Approuver, c’est décider qu’une saisie paraît dans ce portail. La décision porte donc le couple portail/soumission, et non la soumission seule : la même entrée figure dans un annuaire et reste masquée dans un autre.

Refuser ne met rien à la corbeille. Transformer un refus de publication en suppression différée effacerait à terme une donnée que personne n’a demandé d’effacer, et le cœur garderait la trace d’un geste qui n’a jamais été le sien.

Dans l’autre sens, le cœur l’emporte toujours : une soumission mise à la corbeille ou marquée indésirable disparaît du portail à l’instant, quel que soit son état de publication. C’est la condition status = 'active' de la requête qui le garantit — pas un ménage dans l’index, qui pourrait prendre du retard.

Les quatre états et leurs passages

           ┌──────────┐
           │ pending  │◄────────────┐
           └────┬─────┘             │
        ┌───────┴────────┐          │
        ▼                ▼          │
  ┌──────────┐     ┌──────────┐     │
  │ approved │────►│ rejected │─────┘
  └────┬─────┘     └──────────┘
       │ ▲
       ▼ │
   ┌────────┐
   │ hidden │
   └────────┘

Le graphe n’est pas complet, et c’est le point.

rejected ne mène qu’à pending. Un refus se réexamine avant de paraître. Permettre rejected → approved d’un seul clic ferait d’un geste de liste la publication d’un contenu qu’on avait écarté, sans que personne ne l’ait relu.

hidden ne mène qu’à approved. Masquer est un retrait temporaire — le temps d’un événement passé, d’une vérification — et non un refus. Qui veut refuser passe par l’état qui le dit, et le journal garde la différence.

L’absence de décision vaut pending. Pas approved. Sans quoi, le jour où l’on coche « exiger une approbation », tout ce qu’on venait de décider de relire paraîtrait d’un coup.

Être désigné relecteur n’ouvre aucun dossier

La capacité solis_forms_moderate_entries est nouvelle, et la capacité méta solis_forms_moderate_form la résout par formulaire. Modérer, c’est décider ce qui paraît : ce n’est ni modifier le formulaire, ni rembourser un paiement, ni résilier un abonnement. Un relecteur reçoit donc la première sans recevoir les autres, et un gestionnaire de ses propres formulaires ne modère que les siens.

La capacité est exigée d’abord, et la portée vient ensuite dire quels formulaires. L’ordre compte : l’inverse aurait laissé tout gestionnaire de formulaires modérer sans qu’on le lui ait accordé, alors que c’est précisément ce que cette capacité sert à séparer.

Les capacités arrivent aussi sur les sites déjà installés

Capabilities::add_to_administrator() ne s’exécute qu’à l’activation, et une mise à jour par copie de fichiers n’y repasse jamais. Une capacité ajoutée dans une nouvelle version n’aurait donc été attribuée à personne : l’écran de modération aurait répondu « vous n’avez pas la permission » à l’administrateur lui-même.

Le schéma avait déjà son rattrapage ; les capacités n’en avaient pas. Elles en ont un désormais, déclenché par une empreinte de la liste plutôt que par un numéro de version à incrémenter — un numéro s’oublie, une empreinte change parce que la liste a changé.

Ce qu’un portail peut interroger

Les champs cherchables, filtrables et triables sont des sous-ensembles des champs exposés par la vue, et l’intersection est refaite à chaque lecture du réglage. Un champ retiré de la liste blanche de la vue cesse immédiatement d’être cherchable, sans qu’il faille rouvrir l’écran du portail.

C’est la fuite la plus probable de ce module. Rendre un champ cherchable, c’est en recopier la valeur dans une table d’index ; si cette recopie pouvait porter sur un champ non exposé, une recherche bien choisie ferait apparaître ou disparaître des lignes selon une donnée que le portail ne montre pas — ce qui la révèle par déduction, question après question.

UsageTypes admisPourquoi pas les autres
Recherchetexte, paragraphe, courriel, URL, téléphone, nom, adresseUn champ à liste se filtre mieux qu’il ne se cherche, et ce qui est indexé pour lui est la valeur quand le visiteur taperait l’intitulé
Filtrelistes et cases (égalité), dates et nombres (intervalle)Un filtre sur du texte libre obligerait à deviner l’orthographe exacte de l’autre, et ne retrouverait jamais rien
Tridates et nombresTrier du texte dépend de la collation de la base : un ordre qui change d’un hébergeur à l’autre n’est pas un ordre

Les pièces jointes, les signatures et les champs de mise en page n’entrent dans aucune liste. Un champ caché non plus : il porte une valeur technique que le visiteur n’a jamais vue.

Un champ ajouté au formulaire plus tard n’est jamais indexé ni exporté automatiquement. Il faut l’ajouter explicitement.

L’index, et pourquoi il existe

Chercher dans slf_entry_meta supposerait un LIKE sur une colonne LONGTEXT : aucun index ne s’y applique, et le coût croît avec la table entière à chaque frappe.

La table slf_portal_index range donc, à part, les seules valeurs dont une requête a besoin. Elle porte deux natures de lignes :

  • kind = 'value' : la valeur entière, que compare un filtre et sur laquelle s’appuie un tri. Trois colonnes typées — texte, nombre, date — parce que « 9 » vient après « 10 » dans un tri de texte, et qu’un intervalle de dates comparé comme du texte se trompe dès que les formats diffèrent.
  • kind = 'word' : un mot, et autant de lignes que la valeur en compte.

Sans les lignes de mots, chercher « durand » dans « Boulangerie Durand » supposerait un LIKE '%durand%'. Un joker en tête interdit tout usage d’index : la base relit la table entière à chaque frappe, et c’est précisément ce qu’on voulait éviter. Une ligne par mot ramène la recherche à LIKE 'durand%'.

La contrepartie est assumée : la recherche trouve un préfixe de mot, pas une sous-chaîne. « oulangerie » ne trouve pas « Boulangerie ». C’est le prix d’un index qui sert à quelque chose, et la V1 s’y tient tant qu’un moteur plein texte n’aura pas été mesuré sur des données réelles.

Le texte est normalisé des deux côtés par la même fonction : minuscules, accents repliés, ponctuation ramenée à des séparateurs. « Crèmerie » se trouve en tapant « cremerie », et « Saint-Exupéry » en tapant « exupery ». Les écritures non latines traversent sans être touchées et continuent de correspondre à elles-mêmes.

L’index n’est pas une source : la valeur affichée vient toujours de slf_entry_meta, intacte. Il se reconstruit à l’enregistrement des réglages de la vue, se met à jour à la réception d’une soumission et à sa correction, et disparaît avec elle.

Les paramètres d’URL

slf_view     portail visé, quand plusieurs cohabitent sur une page
slf_q        recherche, de 2 à 100 caractères, par préfixe de mots
slf_filter[] égalité sur un champ à liste
slf_from[]   borne basse d'une date ou d'un nombre
slf_to[]     borne haute
slf_sort     champ de tri déclaré, ou rien pour la date de réception
slf_order    asc ou desc
slf_page     page, à partir de 1

Tout est confronté à la configuration : un champ non déclaré n’existe pas, une option absente du champ est écartée, un tri sur un champ non triable retombe sur la date. Un paramètre inconnu est ignoré, non refusé — un lien collé depuis une version antérieure du portail doit montrer la liste, pas une erreur.

slf_view existe parce que deux portails sur une même page partagent la barre d’adresse : sans lui, chercher dans l’un paginerait l’autre.

Les liens sont construits par http_build_query, et non par add_query_arg : ce dernier n’encode pas les valeurs qu’on lui confie, et un terme de recherche contenant une esperluette briserait le lien de la page suivante — qui montrerait alors autre chose que la première.

Tout fonctionne sans JavaScript : un formulaire GET et des liens. La recherche, les filtres, le tri et la pagination sont des changements d’adresse, pas des interactions. Ils se partagent, se mettent en favori, s’ouvrent dans un nouvel onglet, se lisent par un lecteur d’écran, et survivent à une erreur de script du thème.

Deux relecteurs ne s’écrasent pas

Chaque décision porte une révision, que l’écran emporte et que l’écriture exige. Relire la ligne avant d’écrire laisserait exactement l’intervalle qu’on cherche à fermer.

Une action par lot n’est pas une transaction : chaque entrée est contrôlée pour elle-même, et le détail est rendu. Un lot tout-ou-rien aurait annulé quatre-vingt-dix-neuf décisions légitimes parce qu’une entrée venait de passer à la corbeille ; un lot silencieux aurait laissé croire que les cent étaient passées. Cent entrées au maximum par lot.

L’export

Il est distinct de l’export CSV général, et il part de la même interrogation que l’écran.

Le lien ne transporte pas la liste des lignes à exporter : il transporte les critères. Le serveur recalcule la portée — la même que celle du rendu, par la même porte — puis relit. Un export qui recevrait des identifiants exporterait ce qu’on lui demande, et il suffirait d’en ajouter un.

Les colonnes sont les champs exposés, dans l’ordre du formulaire. En sont exclus :

  • les pièces jointes et les signatures, même exposées : le portail en montre l’image par une adresse signée et expirante, tandis qu’une colonne de CSV ne saurait porter que le chemin du fichier — c’est-à-dire un accès permanent, à côté du contrôle qu’on vient d’établir ;
  • les données de paiement et les champs sensibles ;
  • les notes internes et les identifiants techniques.

La date de réception et l’état de publication n’apparaissent que si le portail les a explicitement activés, et l’état seulement pour qui peut modérer : pour un lecteur, toutes les lignes visibles sont approuvées, et la colonne ne dirait rien qu’il ne sache déjà.

Le plafond est un refus, pas une troncature. Au-delà de dix mille lignes, l’écran demande de resserrer les filtres. Tronquer en silence aurait rendu un fichier incomplet qu’on croirait complet — et c’est exactement sur un export qu’on ne s’en aperçoit pas.

Rien n’est écrit sur le disque : le fichier est produit en flux, avec une marque d’ordre des octets pour qu’Excel ne transforme pas « Noël » en « Noël ». Un export déposé dans le dossier des médias survivrait à la demande, resterait lisible par son adresse, et se retrouverait dans les sauvegardes du site.

Chaque export est journalisé : qui, quand, sous quels critères, combien de lignes. Jamais le contenu.

Les liens privés

Un relecteur peut créer un lien à durée obligatoire, d’une heure à trente jours. Il est en lecture seule, n’exporte que si son créateur l’a explicitement voulu, et porte un libellé interne pour s’y retrouver.

Le secret n’existe qu’une fois

Il vient de random_bytes(), est affiché une seule fois à l’écran, et seule son empreinte HMAC est conservée. Qui lit la table ne peut fabriquer aucun lien.

La création rend la page directement, sans redirection : passer par une redirection aurait obligé à poser le secret quelque part entre les deux requêtes — une option temporaire, c’est-à-dire la base de données, c’est-à-dire précisément l’endroit où l’on a décidé qu’il n’irait jamais.

Il ne reste pas dans la barre d’adresse

Une URL se copie dans un message, s’enregistre en favori, part dans l’en-tête Referer de la première image externe de la page, et s’écrit dans les journaux du serveur comme dans l’historique du navigateur.

Le lien est donc consommé une fois : la validation pose un cookie HttpOnly, Secure, SameSite=Lax à courte durée, et la requête repart vers la même page débarrassée du paramètre. On redirige vers l’adresse courante moins un paramètre : il n’y a donc pas de cible à valider, et pas de redirection ouverte possible.

Le cookie ne dispense d’aucun contrôle : il porte la même charge utile que le lien, revérifiée à chaque requête — empreinte, expiration, révocation. Une révocation coupe la session au contrôle suivant, sans qu’il faille tenir une liste de sessions ouvertes.

Ce qu’un lien peut, et ce qu’il ne peut pas

Il restreint, il n’élargit jamais :

  • ses filtres sont scellés à la création et s’ajoutent à ceux de la vue ; un paramètre d’URL cherche à l’intérieur de sa portée, jamais au-delà ;
  • il ne montre jamais une entrée en attente de relecture ;
  • il ne voit aucun champ hors de la liste blanche ;
  • il ne modère rien.

Il affranchit en revanche de la règle d’audience de la vue — connexion exigée, rôles, auteur. C’est l’objet même d’un lien privé : il est donné à quelqu’un qui n’appartient pas à l’audience. Sur une vue réservée à l’auteur, un lien montre donc l’ensemble de ce que ses filtres laissent passer, et l’écran de création le dit.

Les liens expirés ne sont pas effacés. Ils sont refusés, mais la ligne reste : c’est elle qui permet de répondre, six mois plus tard, à « qui avait accès à ce portail et jusqu’à quand ». Un registre qui s’efface tout seul ne documente plus rien.

Cache et indexation

Un portail qui n’est pas ouvert à tous — ou qu’un lien privé vient d’ouvrir — fait répondre à sa page :

Cache-Control: private, no-store, max-age=0
Vary: Cookie
X-Robots-Tag: noindex, nofollow, noarchive

et pose la constante DONOTCACHEPAGE, que WP Rocket, W3 Total Cache, LiteSpeed et Batcache reconnaissent toutes — elles n’écoutent pas les en-têtes.

no-store et pas seulement private : private interdit le cache partagé, pas celui du navigateur, et un poste partagé garderait la liste des candidatures à disposition du suivant dans le bouton « précédent ».

La décision est prise sur wp, avant qu’aucun octet ne soit sorti. Un portail se rend pendant the_content, c’est-à-dire longtemps après que les en-têtes sont partis : un header() posé là ne ferait rien, et pire, il ferait croire que quelque chose a été fait.

Le journal

Une seule table pour les décisions, les exports et les liens privés. Après coup, la question qui se pose n’est jamais « qui a approuvé » ou « qui a exporté » séparément : c’est « qu’est-il sorti de ce portail, quand, et à la demande de qui ».

Il ne contient ni valeur de champ, ni secret de lien. Un journal qui recopie ce qu’il surveille devient la fuite qu’il devait documenter — et il survivrait à la donnée qu’il aurait recopiée.

Il disparaît avec ce qu’il décrit : une note de modération parle de quelqu’un, et il n’existe aucun droit à l’effacement qui épargnerait la note en effaçant la réponse.

Pour les développeurs

Une action est diffusée après chaque décision :

add_action(
	'solis_forms_pro_portal_decided',
	function ( int $entry_id, int $view_id, string $from, string $to ): void {
		// …
	},
	10,
	4
);

La capacité méta se pose comme les autres :

if ( current_user_can( 'solis_forms_moderate_form', $form_id ) ) {
	// …
}

Tables : {prefix}slf_portal_moderation (décision courante, (view_id, entry_id) unique), {prefix}slf_portal_log (journal, ajouté seulement), {prefix}slf_portal_index (index de recherche et de filtre), {prefix}slf_portal_shares (registre des liens, empreintes seules).

Ce que la V1 ne fait pas

  • Pas d’édition libre des entrées depuis l’écran de modération, pas de discussion, pas d’assignation de relecteur, pas de workflow à plusieurs étapes — c’est le rôle du traitement interne, qui est un autre module.
  • Pas de recherche entre plusieurs formulaires, pas de recherche dans le contenu des fichiers, pas de moteur externe.
  • Pas d’URL publique permanente d’une entrée, pas d’API publique, pas de flux de syndication.
  • Une approbation ne déclenche aucun paiement, ne crée aucun compte et ne touche à aucun abonnement.