Ajoute l'historique des versions des questions soumises par les Citoyens
Build and deploy / deploy (push) Successful in 35s

Nouvelle table chariot_submission_history (append-only, comme
points_log) : chariot_submissions ne garde que la valeur actuelle
(upsert), donc sans ce journal les Archontes ne voyaient jamais les
versions précédentes d'une question modifiée plusieurs fois.
submit_chariot_question() y écrit désormais à chaque changement réel.

Sur /char/questions, chaque suggestion affiche un lien "Historique (N)"
(visible seulement si la personne a déjà modifié sa proposition) qui
déplie les versions précédentes avec leur date.
This commit is contained in:
Valentin ROBIN
2026-08-22 00:16:42 +02:00
parent 6badff38af
commit ec6b1920af
4 changed files with 110 additions and 11 deletions
+2 -1
View File
@@ -79,6 +79,7 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo
- RLS `select` : le propriétaire voit sa propre ligne, les juges voient tout (modération) — pas de visibilité entre Citoyens.
- RLS `delete` : réservée aux juges (rejet direct depuis `/char/questions`, pas besoin de RPC pour une suppression simple).
- Gel réutilisant `settings.tribunal_date` (même déclencheur que `submit_icarus_score`) plutôt qu'une nouvelle colonne dédiée.
- `chariot_submission_history` : journal append-only (comme `points_log`) des versions successives d'une proposition — `chariot_submissions` n'upsertant que la valeur courante, sans ce journal les Archontes ne verraient jamais les versions précédentes d'une question modifiée plusieurs fois. Même RLS (propriétaire + juges), écrit uniquement par `submit_chariot_question()`. Affiché en repli sur `/char/questions` (« Historique (N) » par proposition, versions précédentes uniquement — la version actuelle est déjà affichée au-dessus).
### Colonnes sensibles : RLS + trigger, jamais confiance au client
@@ -100,7 +101,7 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè
- `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu.
- `award_icarus_points_if_due()`**pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`).
- `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne.
- `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé).
- `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client.
---
+14
View File
@@ -17,6 +17,12 @@ export type SubmissionRow = {
profiles: { pseudo: string; avatar_url: string | null } | null;
};
export type SubmissionHistoryRow = {
user_id: string;
text: string;
created_at: string;
};
export default async function CharQuestionsPage() {
const supabase = await createClient();
const {
@@ -58,6 +64,13 @@ export default async function CharQuestionsPage() {
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>();
// Versions précédentes des propositions (chariot_submissions ne garde que
// la version actuelle) — pour la section "Historique" de la modération.
const { data: submissionHistory } = await supabase
.from("chariot_submission_history")
.select("user_id, text, created_at")
.order("created_at", { ascending: false });
return (
<div className="mx-auto w-full max-w-2xl px-4 py-8">
<div className="mb-6 text-center">
@@ -76,6 +89,7 @@ export default async function CharQuestionsPage() {
initialQuestions={questions ?? []}
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
initialSubmissions={submissions ?? []}
initialSubmissionHistory={submissionHistory ?? []}
/>
</div>
);
+54 -4
View File
@@ -4,7 +4,15 @@ import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar";
import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { QuestionRow, SubmissionRow } from "./page";
import type { QuestionRow, SubmissionHistoryRow, SubmissionRow } from "./page";
function formatHistoryDate(iso: string): string {
return new Date(iso).toLocaleString("fr-FR", {
dateStyle: "short",
timeStyle: "short",
timeZone: "Europe/Paris",
});
}
function QuestionForm({
initial,
@@ -187,14 +195,17 @@ function QuestionRowItem({
function SubmissionRowItem({
submission,
pastVersions,
onAddToBank,
onReject,
}: {
submission: SubmissionRow;
pastVersions: SubmissionHistoryRow[];
onAddToBank: () => void;
onReject: () => void;
}) {
const [confirmingReject, setConfirmingReject] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const profile = submission.profiles;
const pseudo = profile?.pseudo ?? "un Citoyen";
@@ -204,6 +215,28 @@ function SubmissionRowItem({
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-mut">{pseudo}</p>
<p className="text-text-marble">{submission.text}</p>
{pastVersions.length > 0 && (
<>
<button
type="button"
onClick={() => setShowHistory((v) => !v)}
className="mt-1 text-[0.7rem] font-medium text-text-mut underline hover:text-text-marble"
>
{showHistory ? "Masquer l'historique" : `Historique (${pastVersions.length})`}
</button>
{showHistory && (
<ul className="mt-1 flex flex-col gap-1 border-l-2 border-gold/20 pl-2">
{pastVersions.map((version) => (
<li key={version.created_at} className="text-xs text-text-mut">
<span className="text-text-marble">{version.text}</span>
{" — "}
{formatHistoryDate(version.created_at)}
</li>
))}
</ul>
)}
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
@@ -242,19 +275,22 @@ export function QuestionsView({
initialQuestions,
initialRevealedId,
initialSubmissions,
initialSubmissionHistory,
}: {
initialQuestions: QuestionRow[];
initialRevealedId: string | null;
initialSubmissions: SubmissionRow[];
initialSubmissionHistory: SubmissionHistoryRow[];
}) {
const [questions, setQuestions] = useState(initialQuestions);
const [revealedId, setRevealedId] = useState(initialRevealedId);
const [submissions, setSubmissions] = useState(initialSubmissions);
const [submissionHistory, setSubmissionHistory] = useState(initialSubmissionHistory);
const [adding, setAdding] = useState(false);
const refetch = useCallback(async () => {
const supabase = createClient();
const [{ data: q }, { data: s }, { data: sub }] = await Promise.all([
const [{ data: q }, { data: s }, { data: sub }, { data: hist }] = await Promise.all([
supabase
.from("chariot_questions")
.select("id, text, position, created_at")
@@ -266,10 +302,15 @@ export function QuestionsView({
.select("user_id, text, updated_at, profiles(pseudo, avatar_url)")
.order("updated_at", { ascending: true })
.returns<SubmissionRow[]>(),
supabase
.from("chariot_submission_history")
.select("user_id, text, created_at")
.order("created_at", { ascending: false }),
]);
if (q) setQuestions(q);
if (s) setRevealedId(s.chariot_revealed_question_id);
if (sub) setSubmissions(sub);
if (hist) setSubmissionHistory(hist);
}, []);
useEffect(() => {
@@ -284,6 +325,7 @@ export function QuestionsView({
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_submissions" }, refetch)
.on("postgres_changes", { event: "*", schema: "public", table: "chariot_submission_history" }, refetch)
.subscribe();
});
@@ -336,14 +378,22 @@ export function QuestionsView({
Suggestions des Citoyens
</p>
<ul className="flex flex-col divide-y divide-gold/10">
{submissions.map((submission) => (
{submissions.map((submission) => {
// Trié desc : la première entrée est la version actuelle
// (déjà affichée au-dessus), le reste est l'historique.
const pastVersions = submissionHistory
.filter((h) => h.user_id === submission.user_id)
.slice(1);
return (
<SubmissionRowItem
key={submission.user_id}
submission={submission}
pastVersions={pastVersions}
onAddToBank={() => addSubmissionToBank(submission)}
onReject={() => rejectSubmission(submission.user_id)}
/>
))}
);
})}
</ul>
</div>
)}
+34
View File
@@ -899,6 +899,37 @@ exception
when duplicate_object then null;
end $$;
-- Historique append-only (comme points_log) des versions successives d'une
-- proposition : chariot_submissions ne garde que la valeur courante (upsert),
-- donc sans ce journal les Archontes ne verraient jamais les versions
-- précédentes d'une question modifiée plusieurs fois. Écrit uniquement par
-- submit_chariot_question() (aucune policy insert, comme points_log).
create table if not exists public.chariot_submission_history (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
text text not null,
created_at timestamptz not null default now()
);
alter table public.chariot_submission_history enable row level security;
revoke insert, update, delete on public.chariot_submission_history from authenticated, anon;
grant select on public.chariot_submission_history to authenticated;
drop policy if exists "chariot_submission_history readable by owner or judges" on public.chariot_submission_history;
create policy "chariot_submission_history readable by owner or judges"
on public.chariot_submission_history for select to authenticated
using (
user_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')
);
do $$
begin
alter publication supabase_realtime add table public.chariot_submission_history;
exception
when duplicate_object then null;
end $$;
-- Seul point d'entrée pour proposer/modifier sa question. Réutilise
-- settings.tribunal_date comme déclencheur de gel — même philosophie que
-- submit_icarus_score() : une fois l'Agora commencée, no-op silencieux
@@ -943,6 +974,9 @@ begin
set text = excluded.text,
updated_at = now();
insert into public.chariot_submission_history (user_id, text)
values (auth.uid(), trim(p_text));
select * into v_row from public.chariot_submissions where user_id = auth.uid();
return v_row;
end;