diff --git a/CLAUDE.md b/CLAUDE.md index f2db1a0..4f6b76a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. --- diff --git a/src/app/char/questions/page.tsx b/src/app/char/questions/page.tsx index d5999a9..785d56c 100644 --- a/src/app/char/questions/page.tsx +++ b/src/app/char/questions/page.tsx @@ -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(); + // 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 (
@@ -76,6 +89,7 @@ export default async function CharQuestionsPage() { initialQuestions={questions ?? []} initialRevealedId={settings?.chariot_revealed_question_id ?? null} initialSubmissions={submissions ?? []} + initialSubmissionHistory={submissionHistory ?? []} />
); diff --git a/src/app/char/questions/questions-view.tsx b/src/app/char/questions/questions-view.tsx index 05ab7f4..187a87d 100644 --- a/src/app/char/questions/questions-view.tsx +++ b/src/app/char/questions/questions-view.tsx @@ -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({

{pseudo}

{submission.text}

+ {pastVersions.length > 0 && ( + <> + + {showHistory && ( +
    + {pastVersions.map((version) => ( +
  • + {version.text} + {" — "} + {formatHistoryDate(version.created_at)} +
  • + ))} +
+ )} + + )}
)} diff --git a/supabase/schema.sql b/supabase/schema.sql index d1a137e..fde47bf 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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;