diff --git a/CLAUDE.md b/CLAUDE.md index f077d9d..2a2374b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,17 +105,21 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `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 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. -- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris) **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes). -- `post_wall_note(p_text)` — authenticated, rejette les juges (seuls les Citoyens publient), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). +- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris), y compris `image_url`, **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes). +- `post_wall_note(p_text, p_image_url default null)` — authenticated, rejette les juges (seuls les Citoyens publient), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`). +- `wall_note_reaction_counts()` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes du jour — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total. +- `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair). - `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui). - `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_today` : le contenu est public depuis le classement, l'auteur ne l'est jamais. - `cast_urn_vote(p_target_id, p_reason default null)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression. `p_reason` est une justification facultative (≤ 200 caractères, `nullif(trim(...), '')` pour normaliser une chaîne vide en `null`). ### `public.wall_notes` (Le Mur de la Honte, V9) -- `id, user_id, text, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. +- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. `image_url` est facultative, pointe vers le bucket `wall-images`. - RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`. - Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine. +- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie. +- `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`. ### `public.urn_votes` (L'Urne de l'Agora, V10) diff --git a/src/app/mur/mur-view.tsx b/src/app/mur/mur-view.tsx index 5af8cca..63c8e59 100644 --- a/src/app/mur/mur-view.tsx +++ b/src/app/mur/mur-view.tsx @@ -1,12 +1,17 @@ "use client"; -import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } from "react"; +import Image from "next/image"; import { Avatar } from "@/components/avatar"; +import { IconCamera, IconClose } from "@/components/icons"; +import { resizeImageToBlob } from "@/lib/image"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; -import type { WallHistoryRow, WallNoteRow } from "./page"; +import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page"; const MAX_LENGTH = 200; const MAX_NOTES_PER_DAY = 10; +const MAX_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024; +const REACTION_EMOJIS = ["👍", "😂", "😱", "❤️", "🔥"]; // Rotation déterministe par note (jamais Math.random() au rendu, cf. la // même contrainte déjà rencontrée cette session avec les hooks React) — @@ -41,55 +46,143 @@ function formatDateLabel(dateKey: string): string { return label.charAt(0).toUpperCase() + label.slice(1); } -function NoteCard({ note }: { note: WallNoteRow }) { +function buildReactionCounts(rows: ReactionCountRow[]): Record> { + const map: Record> = {}; + for (const row of rows) { + if (!map[row.note_id]) map[row.note_id] = {}; + map[row.note_id][row.emoji] = row.count; + } + return map; +} + +function buildMyReactions(rows: MyReactionRow[]): Record> { + const map: Record> = {}; + for (const row of rows) { + if (!map[row.note_id]) map[row.note_id] = new Set(); + map[row.note_id].add(row.emoji); + } + return map; +} + +function ReactionBar({ + counts, + mine, + onToggle, +}: { + counts: Record; + mine: Set; + onToggle: (emoji: string) => void; +}) { + return ( +
+ {REACTION_EMOJIS.map((emoji) => { + const count = counts[emoji] ?? 0; + const active = mine.has(emoji); + return ( + + ); + })} +
+ ); +} + +function NoteCard({ + note, + reactionCounts, + myReactions, + onToggleReaction, +}: { + note: WallNoteRow; + reactionCounts: Record; + myReactions: Set; + onToggleReaction: (emoji: string) => void; +}) { return (
-

{note.text}

- +
+ {note.image_url && ( + + )} +

{note.text}

+
+
+ + +
); } export function MurView({ isJudge, + currentUserId, initialNotes, initialMyNoteToday, initialHistory, + initialReactionCounts, + initialMyReactions, }: { isJudge: boolean; + currentUserId: string; initialNotes: WallNoteRow[]; initialMyNoteToday: WallNoteRow | null; initialHistory: WallHistoryRow[]; + initialReactionCounts: ReactionCountRow[]; + initialMyReactions: MyReactionRow[]; }) { const [notes, setNotes] = useState(initialNotes); const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday); const [history, setHistory] = useState(initialHistory); + const [reactionCounts, setReactionCounts] = useState(() => buildReactionCounts(initialReactionCounts)); + const [myReactions, setMyReactions] = useState(() => buildMyReactions(initialMyReactions)); const [text, setText] = useState(""); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [imageError, setImageError] = useState(null); const [posting, setPosting] = useState(false); const [error, setError] = useState(null); const [historyDateFilter, setHistoryDateFilter] = useState("all"); const refetch = useCallback(async () => { const supabase = createClient(); - const [{ data: todayNotes }, { data: myNotes }, { data: hist }] = await Promise.all([ - supabase.rpc("wall_notes_today"), - isJudge - ? Promise.resolve({ data: null }) - : supabase - .from("wall_notes") - .select("id, text, created_at") - .order("created_at", { ascending: false }), - isJudge - ? supabase - .from("wall_notes") - .select("id, user_id, text, created_at, profiles(pseudo, avatar_url)") - .order("created_at", { ascending: false }) - .returns() - : Promise.resolve({ data: null }), - ]); + const [{ data: todayNotes }, { data: myNotes }, { data: hist }, { data: counts }, { data: mine }] = + await Promise.all([ + supabase.rpc("wall_notes_today"), + isJudge + ? Promise.resolve({ data: null }) + : supabase + .from("wall_notes") + .select("id, text, image_url, created_at") + .order("created_at", { ascending: false }), + isJudge + ? supabase + .from("wall_notes") + .select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)") + .order("created_at", { ascending: false }) + .returns() + : Promise.resolve({ data: null }), + supabase.rpc("wall_note_reaction_counts"), + supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", currentUserId), + ]); if (todayNotes) setNotes(todayNotes); if (myNotes) { const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); @@ -100,7 +193,9 @@ export function MurView({ ); } if (hist) setHistory(hist); - }, [isJudge]); + if (counts) setReactionCounts(buildReactionCounts(counts)); + if (mine) setMyReactions(buildMyReactions(mine)); + }, [isJudge, currentUserId]); useEffect(() => { const supabase = createClient(); @@ -112,6 +207,7 @@ export function MurView({ channel = supabase .channel("wall-notes-changes") .on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "wall_note_reactions" }, refetch) .subscribe(); }); @@ -121,6 +217,37 @@ export function MurView({ }; }, [refetch]); + function handleImageChange(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + if (!file.type.startsWith("image/")) { + setImageError("Choisis une image."); + return; + } + if (file.size > MAX_IMAGE_SOURCE_BYTES) { + setImageError("Image trop lourde, choisis-en une autre."); + return; + } + + setImageError(null); + setImageFile(file); + setImagePreview((current) => { + if (current) URL.revokeObjectURL(current); + return URL.createObjectURL(file); + }); + } + + function clearImage() { + setImageFile(null); + setImagePreview((current) => { + if (current) URL.revokeObjectURL(current); + return null; + }); + setImageError(null); + } + async function handleSubmit(event: FormEvent) { event.preventDefault(); setError(null); @@ -133,7 +260,33 @@ export function MurView({ setPosting(true); const supabase = createClient(); - const { error: rpcError } = await supabase.rpc("post_wall_note", { p_text: trimmed }); + + let imageUrl: string | null = null; + if (imageFile) { + try { + const blob = await resizeImageToBlob(imageFile); + const path = `${crypto.randomUUID()}.webp`; + const { error: uploadError } = await supabase.storage + .from("wall-images") + .upload(path, blob, { contentType: "image/webp" }); + if (uploadError) { + setPosting(false); + setError("Impossible d'envoyer l'image, réessaie."); + return; + } + const { data: publicUrlData } = supabase.storage.from("wall-images").getPublicUrl(path); + imageUrl = publicUrlData.publicUrl; + } catch { + setPosting(false); + setError("Impossible de traiter l'image, réessaie."); + return; + } + } + + const { error: rpcError } = await supabase.rpc("post_wall_note", { + p_text: trimmed, + p_image_url: imageUrl, + }); setPosting(false); if (rpcError) { @@ -149,9 +302,34 @@ export function MurView({ } setText(""); + clearImage(); refetch(); } + async function handleToggleReaction(noteId: string, emoji: string) { + // Optimiste : le canal Realtime confirmera (ou corrigera) juste après. + setMyReactions((current) => { + const next = new Set(current[noteId] ?? []); + const wasActive = next.has(emoji); + if (wasActive) next.delete(emoji); + else next.add(emoji); + return { ...current, [noteId]: next }; + }); + setReactionCounts((current) => { + const wasActive = myReactions[noteId]?.has(emoji) ?? false; + const noteCounts = { ...(current[noteId] ?? {}) }; + noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1)); + return { ...current, [noteId]: noteCounts }; + }); + + const supabase = createClient(); + const { error: rpcError } = await supabase.rpc("toggle_wall_note_reaction", { + p_note_id: noteId, + p_emoji: emoji, + }); + if (rpcError) refetch(); + } + const wallFull = notes.length >= MAX_NOTES_PER_DAY; const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) => @@ -172,7 +350,13 @@ export function MurView({ ) : (
{notes.map((note) => ( - + handleToggleReaction(note.id, emoji)} + /> ))}
)} @@ -183,6 +367,15 @@ export function MurView({ {myNoteToday ? (

Ta note d'aujourd'hui est publiée.

+ {myNoteToday.image_url && ( + + )}

{myNoteToday.text}

@@ -203,6 +396,35 @@ export function MurView({

{text.length}/{MAX_LENGTH}

+ + {imagePreview ? ( +
+ + +
+ ) : ( + + )} + {imageError &&

{imageError}

} + {error && (

{error} @@ -257,6 +479,15 @@ export function MurView({ {note.profiles?.pseudo ?? "un Citoyen"} — {formatDateTime(note.created_at)}

{note.text}

+ {note.image_url && ( + + )}
))} diff --git a/src/app/mur/page.tsx b/src/app/mur/page.tsx index d6744ac..6a7ae80 100644 --- a/src/app/mur/page.tsx +++ b/src/app/mur/page.tsx @@ -5,6 +5,7 @@ import { MurView } from "./mur-view"; export type WallNoteRow = { id: string; text: string; + image_url: string | null; created_at: string; }; @@ -12,10 +13,14 @@ export type WallHistoryRow = { id: string; user_id: string; text: string; + image_url: string | null; created_at: string; profiles: { pseudo: string; avatar_url: string | null } | null; }; +export type ReactionCountRow = { note_id: string; emoji: string; count: number }; +export type MyReactionRow = { note_id: string; emoji: string }; + function isToday(iso: string): boolean { const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); const noteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); @@ -39,13 +44,17 @@ export default async function MurPage() { .single(); const isJudge = callerProfile?.role === "judge"; - const { data: todayNotes } = await supabase.rpc("wall_notes_today"); + const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }] = await Promise.all([ + supabase.rpc("wall_notes_today"), + supabase.rpc("wall_note_reaction_counts"), + supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id), + ]); let myNoteToday: WallNoteRow | null = null; if (!isJudge) { const { data: myNotes } = await supabase .from("wall_notes") - .select("id, text, created_at") + .select("id, text, image_url, created_at") .eq("user_id", user.id) .order("created_at", { ascending: false }); myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null; @@ -55,7 +64,7 @@ export default async function MurPage() { if (isJudge) { const { data } = await supabase .from("wall_notes") - .select("id, user_id, text, created_at, profiles(pseudo, avatar_url)") + .select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)") .order("created_at", { ascending: false }) .returns(); history = data ?? []; @@ -71,9 +80,12 @@ export default async function MurPage() { ); diff --git a/src/lib/image.ts b/src/lib/image.ts index e1b4b0a..3759ebf 100644 --- a/src/lib/image.ts +++ b/src/lib/image.ts @@ -38,3 +38,38 @@ export async function cropImageToBlob( ); }); } + +// Redimensionne (sans jamais agrandir) au format d'origine — contrairement +// à cropImageToBlob qui force un carré pour les avatars — à maxDimension +// sur le plus grand côté, et compresse en WebP avant l'envoi vers le +// bucket. Le bucket a aussi sa propre limite de taille côté serveur +// (défense en profondeur) : cette compression n'est qu'un premier filtre, +// pas la seule garantie. +export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise { + const src = URL.createObjectURL(file); + try { + const image = await loadImage(src); + const scale = Math.min(1, maxDimension / Math.max(image.width, image.height)); + const outputWidth = Math.round(image.width * scale); + const outputHeight = Math.round(image.height * scale); + + const canvas = document.createElement("canvas"); + canvas.width = outputWidth; + canvas.height = outputHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Canvas non supporté par ce navigateur"); + } + ctx.drawImage(image, 0, 0, outputWidth, outputHeight); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))), + "image/webp", + quality, + ); + }); + } finally { + URL.revokeObjectURL(src); + } +} diff --git a/supabase/schema.sql b/supabase/schema.sql index a970784..6c1b29d 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1034,6 +1034,9 @@ grant execute on function public.admin_list_members() to authenticated; -- donner une raison de rouvrir l'app tous les jours de la semaine, pas -- seulement pour le classement. L'anonymat n'est qu'à moitié réel : les -- Archontes voient l'historique complet avec l'auteur de chaque note. +-- Chaque note peut porter une image facultative (bucket wall-images, +-- compressée côté client, limite de taille côté serveur) et des réactions +-- emoji, elles aussi anonymes (voir wall_note_reactions plus bas). create table if not exists public.wall_notes ( id uuid primary key default gen_random_uuid(), @@ -1042,6 +1045,9 @@ create table if not exists public.wall_notes ( created_at timestamptz not null default now() ); +-- URL publique dans le bucket wall-images (voir plus bas), facultative. +alter table public.wall_notes add column if not exists image_url text; + alter table public.wall_notes enable row level security; revoke insert, update, delete on public.wall_notes from authenticated, anon; grant select on public.wall_notes to authenticated; @@ -1064,14 +1070,18 @@ end $$; -- Seul moyen pour un Citoyen de voir les notes des autres sans jamais -- exposer qui les a écrites côté client : la RLS ne peut pas masquer une -- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le --- filtre elle-même et ne renvoie jamais user_id. +-- filtre elle-même et ne renvoie jamais user_id. Le type de retour change +-- (ajout d'image_url) : drop explicite requis, create or replace ne peut +-- pas changer le type de retour d'une fonction existante. +drop function if exists public.wall_notes_today(); + create or replace function public.wall_notes_today() -returns table (id uuid, text text, created_at timestamptz) +returns table (id uuid, text text, image_url text, created_at timestamptz) language sql security definer set search_path = public as $$ - select w.id, w.text, w.created_at + select w.id, w.text, w.image_url, w.created_at from public.wall_notes w where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date order by w.created_at asc; @@ -1084,8 +1094,12 @@ grant execute on function public.wall_notes_today() to authenticated; -- mécanique de toute la semaine. Petite fenêtre de course possible si deux -- personnes postent la même seconde alors qu'il reste 1 place (double -- insertion à 11) — acceptée sciemment, comme d'autres arbitrages similaires --- déjà faits dans le projet pour un groupe de ~12 amis. -create or replace function public.post_wall_note(p_text text) +-- déjà faits dans le projet pour un groupe de ~12 amis. La signature change +-- (ajout de p_image_url) : l'ancienne (text) est explicitement supprimée, +-- sinon create or replace créerait une 2e surcharge au lieu de remplacer. +drop function if exists public.post_wall_note(text); + +create or replace function public.post_wall_note(p_text text, p_image_url text default null) returns public.wall_notes language plpgsql security definer @@ -1096,6 +1110,7 @@ declare v_today date := (now() at time zone 'Europe/Paris')::date; v_already_posted boolean; v_count_today int; + v_image_url text := nullif(trim(coalesce(p_image_url, '')), ''); v_row public.wall_notes; begin if auth.uid() is null then @@ -1114,6 +1129,13 @@ begin raise exception 'note trop longue'; end if; + -- Doit venir du bucket wall-images géré par l'app, jamais une URL + -- arbitraire (le bucket a lui-même une limite de taille/mime, voir plus + -- bas — cette vérification empêche seulement d'y stocker n'importe quoi). + if v_image_url is not null and v_image_url not like '%/wall-images/%' then + raise exception 'image invalide'; + end if; + select exists ( select 1 from public.wall_notes where user_id = auth.uid() @@ -1130,15 +1152,130 @@ begin raise exception 'wall full today'; end if; - insert into public.wall_notes (user_id, text) - values (auth.uid(), trim(p_text)) + insert into public.wall_notes (user_id, text, image_url) + values (auth.uid(), trim(p_text), v_image_url) returning * into v_row; return v_row; end; $$; -grant execute on function public.post_wall_note(text) to authenticated; +grant execute on function public.post_wall_note(text, text) to authenticated; + +-- Bucket dédié aux images du Mur (pas "avatars") : chemin d'objet +-- volontairement SANS le user_id (contrairement aux avatars) pour ne +-- jamais faire fuiter l'auteur via l'URL publique de l'image — cohérent +-- avec l'anonymat de wall_notes_today. file_size_limit + allowed_mime_types +-- bornent le stockage côté Supabase (coût) ; le client compresse déjà +-- l'image bien en dessous de cette limite avant l'upload +-- (resizeImageToBlob), qui n'est qu'un filet de sécurité, pas la seule +-- garantie. +insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) +values ('wall-images', 'wall-images', true, 3145728, array['image/webp', 'image/jpeg', 'image/png']) +on conflict (id) do update set + public = excluded.public, + file_size_limit = excluded.file_size_limit, + allowed_mime_types = excluded.allowed_mime_types; + +drop policy if exists "Wall images are publicly viewable" on storage.objects; +create policy "Wall images are publicly viewable" + on storage.objects for select + using (bucket_id = 'wall-images'); + +drop policy if exists "Citizens can upload wall images" on storage.objects; +create policy "Citizens can upload wall images" + on storage.objects for insert + to authenticated + with check ( + bucket_id = 'wall-images' + and exists (select 1 from public.profiles p where p.id = auth.uid() and p.role <> 'judge') + ); + +-- Réactions sur les notes ------------------------------------------------- +-- Même principe d'anonymat que les notes elles-mêmes : chacun ne voit que +-- ses propres réactions (pour savoir lesquelles sont déjà activées), le +-- compte agrégé par note et par emoji est exposé séparément par +-- wall_note_reaction_counts(), sans jamais révéler qui a réagi. +create table if not exists public.wall_note_reactions ( + id bigint generated always as identity primary key, + note_id uuid not null references public.wall_notes (id) on delete cascade, + user_id uuid not null references public.profiles (id) on delete cascade, + emoji text not null check (emoji in ('👍', '😂', '😱', '❤️', '🔥')), + created_at timestamptz not null default now(), + unique (note_id, user_id, emoji) +); + +alter table public.wall_note_reactions enable row level security; +revoke insert, update, delete on public.wall_note_reactions from authenticated, anon; +grant select on public.wall_note_reactions to authenticated; + +drop policy if exists "wall_note_reactions readable by reactor only" on public.wall_note_reactions; +create policy "wall_note_reactions readable by reactor only" + on public.wall_note_reactions for select to authenticated + using (user_id = auth.uid()); + +do $$ +begin + alter publication supabase_realtime add table public.wall_note_reactions; +exception + when duplicate_object then null; +end $$; + +create or replace function public.wall_note_reaction_counts() +returns table (note_id uuid, emoji text, count bigint) +language sql +security definer +set search_path = public +as $$ + select r.note_id, r.emoji, count(*) as count + from public.wall_note_reactions r + join public.wall_notes w on w.id = r.note_id + where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date + group by r.note_id, r.emoji; +$$; + +grant execute on function public.wall_note_reaction_counts() to authenticated; + +-- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul +-- aller-retour : un delete puis, si rien n'a été supprimé, un insert — +-- évite une lecture préalable puis un choix côté client entre 2 RPC. +create or replace function public.toggle_wall_note_reaction(p_note_id uuid, p_emoji text) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +declare + v_allowed constant text[] := array['👍', '😂', '😱', '❤️', '🔥']; + v_deleted int; +begin + if auth.uid() is null then + raise exception 'authentication required'; + end if; + + if not (p_emoji = any(v_allowed)) then + raise exception 'emoji non autorisé'; + end if; + + if not exists (select 1 from public.wall_notes where id = p_note_id) then + raise exception 'note introuvable'; + end if; + + delete from public.wall_note_reactions + where note_id = p_note_id and user_id = auth.uid() and emoji = p_emoji; + get diagnostics v_deleted = row_count; + + if v_deleted > 0 then + return false; + end if; + + insert into public.wall_note_reactions (note_id, user_id, emoji) + values (p_note_id, auth.uid(), p_emoji); + return true; +end; +$$; + +grant execute on function public.toggle_wall_note_reaction(uuid, text) to authenticated; -- 15. L'Urne de l'Agora ------------------------------------------------------- -- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour