Ajoute l'historique des versions des questions soumises par les Citoyens
Build and deploy / deploy (push) Successful in 35s
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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
<SubmissionRowItem
|
||||
key={submission.user_id}
|
||||
submission={submission}
|
||||
onAddToBank={() => addSubmissionToBank(submission)}
|
||||
onReject={() => rejectSubmission(submission.user_id)}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user