Ajoute images et réactions sur les notes du Mur de la Honte
Build and deploy / deploy (push) Successful in 36s

Chaque note peut désormais porter une image facultative : compressée et
redimensionnée côté client (resizeImageToBlob, sans forcer un carré comme
pour les avatars) avant l'upload, avec une limite dure côté bucket
Supabase (3 Mo, webp/jpeg/png uniquement) en filet de sécurité. Le chemin
de stockage ne contient jamais le user_id, contrairement aux avatars,
pour ne pas faire fuiter l'auteur via l'URL publique de l'image.

Ajoute aussi des réactions emoji (👍😂😱❤️🔥) sur chaque note, avec le
même principe d'anonymat que le reste du Mur : le total par emoji est
public (wall_note_reaction_counts), mais personne ne voit qui a réagi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 23:01:40 +02:00
parent e8a9a0ddb9
commit 47ca05f1c6
5 changed files with 457 additions and 38 deletions
+145 -8
View File
@@ -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