Ajoute images et réactions sur les notes du Mur de la Honte
Build and deploy / deploy (push) Successful in 36s
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:
@@ -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.
|
- `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é).
|
- `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.
|
- `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).
|
- `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)` — 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).
|
- `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_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.
|
- `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`).
|
- `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)
|
### `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()`.
|
- 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.
|
- 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)
|
### `public.urn_votes` (L'Urne de l'Agora, V10)
|
||||||
|
|
||||||
|
|||||||
+255
-24
@@ -1,12 +1,17 @@
|
|||||||
"use client";
|
"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 { Avatar } from "@/components/avatar";
|
||||||
|
import { IconCamera, IconClose } from "@/components/icons";
|
||||||
|
import { resizeImageToBlob } from "@/lib/image";
|
||||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
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_LENGTH = 200;
|
||||||
const MAX_NOTES_PER_DAY = 10;
|
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
|
// 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) —
|
// 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);
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteCard({ note }: { note: WallNoteRow }) {
|
function buildReactionCounts(rows: ReactionCountRow[]): Record<string, Record<string, number>> {
|
||||||
|
const map: Record<string, Record<string, number>> = {};
|
||||||
|
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<string, Set<string>> {
|
||||||
|
const map: Record<string, Set<string>> = {};
|
||||||
|
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<string, number>;
|
||||||
|
mine: Set<string>;
|
||||||
|
onToggle: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{REACTION_EMOJIS.map((emoji) => {
|
||||||
|
const count = counts[emoji] ?? 0;
|
||||||
|
const active = mine.has(emoji);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(emoji)}
|
||||||
|
className={`flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition ${
|
||||||
|
active ? "border-gold bg-gold/20" : "border-gold/20 hover:bg-gold/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{emoji}</span>
|
||||||
|
{count > 0 && <span className="text-[0.6rem] text-text-mut">{count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteCard({
|
||||||
|
note,
|
||||||
|
reactionCounts,
|
||||||
|
myReactions,
|
||||||
|
onToggleReaction,
|
||||||
|
}: {
|
||||||
|
note: WallNoteRow;
|
||||||
|
reactionCounts: Record<string, number>;
|
||||||
|
myReactions: Set<string>;
|
||||||
|
onToggleReaction: (emoji: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="marble-surface flex min-h-[7rem] flex-col justify-between gap-2 rounded-lg border border-gold/30 p-3 shadow-md"
|
className="marble-surface flex min-h-[7rem] flex-col justify-between gap-2 rounded-lg border border-gold/30 p-3 shadow-md"
|
||||||
style={{ transform: `rotate(${rotationFor(note.id)}deg)` }}
|
style={{ transform: `rotate(${rotationFor(note.id)}deg)` }}
|
||||||
>
|
>
|
||||||
<p className="text-sm text-text-marble">{note.text}</p>
|
<div className="flex flex-col gap-2">
|
||||||
<span className="self-end h-2 w-2 rounded-full bg-oxblood" aria-hidden />
|
{note.image_url && (
|
||||||
|
<Image
|
||||||
|
src={note.image_url}
|
||||||
|
alt=""
|
||||||
|
width={300}
|
||||||
|
height={200}
|
||||||
|
className="max-h-32 w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-text-marble">{note.text}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<ReactionBar counts={reactionCounts} mine={myReactions} onToggle={onToggleReaction} />
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-oxblood" aria-hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MurView({
|
export function MurView({
|
||||||
isJudge,
|
isJudge,
|
||||||
|
currentUserId,
|
||||||
initialNotes,
|
initialNotes,
|
||||||
initialMyNoteToday,
|
initialMyNoteToday,
|
||||||
initialHistory,
|
initialHistory,
|
||||||
|
initialReactionCounts,
|
||||||
|
initialMyReactions,
|
||||||
}: {
|
}: {
|
||||||
isJudge: boolean;
|
isJudge: boolean;
|
||||||
|
currentUserId: string;
|
||||||
initialNotes: WallNoteRow[];
|
initialNotes: WallNoteRow[];
|
||||||
initialMyNoteToday: WallNoteRow | null;
|
initialMyNoteToday: WallNoteRow | null;
|
||||||
initialHistory: WallHistoryRow[];
|
initialHistory: WallHistoryRow[];
|
||||||
|
initialReactionCounts: ReactionCountRow[];
|
||||||
|
initialMyReactions: MyReactionRow[];
|
||||||
}) {
|
}) {
|
||||||
const [notes, setNotes] = useState(initialNotes);
|
const [notes, setNotes] = useState(initialNotes);
|
||||||
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
|
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
|
||||||
const [history, setHistory] = useState(initialHistory);
|
const [history, setHistory] = useState(initialHistory);
|
||||||
|
const [reactionCounts, setReactionCounts] = useState(() => buildReactionCounts(initialReactionCounts));
|
||||||
|
const [myReactions, setMyReactions] = useState(() => buildMyReactions(initialMyReactions));
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [imageError, setImageError] = useState<string | null>(null);
|
||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [historyDateFilter, setHistoryDateFilter] = useState("all");
|
const [historyDateFilter, setHistoryDateFilter] = useState("all");
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
const refetch = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [{ data: todayNotes }, { data: myNotes }, { data: hist }] = await Promise.all([
|
const [{ data: todayNotes }, { data: myNotes }, { data: hist }, { data: counts }, { data: mine }] =
|
||||||
supabase.rpc("wall_notes_today"),
|
await Promise.all([
|
||||||
isJudge
|
supabase.rpc("wall_notes_today"),
|
||||||
? Promise.resolve({ data: null })
|
isJudge
|
||||||
: supabase
|
? Promise.resolve({ data: null })
|
||||||
.from("wall_notes")
|
: supabase
|
||||||
.select("id, text, created_at")
|
.from("wall_notes")
|
||||||
.order("created_at", { ascending: false }),
|
.select("id, text, image_url, created_at")
|
||||||
isJudge
|
.order("created_at", { ascending: false }),
|
||||||
? supabase
|
isJudge
|
||||||
.from("wall_notes")
|
? supabase
|
||||||
.select("id, user_id, text, created_at, profiles(pseudo, avatar_url)")
|
.from("wall_notes")
|
||||||
.order("created_at", { ascending: false })
|
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
|
||||||
.returns<WallHistoryRow[]>()
|
.order("created_at", { ascending: false })
|
||||||
: Promise.resolve({ data: null }),
|
.returns<WallHistoryRow[]>()
|
||||||
]);
|
: 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 (todayNotes) setNotes(todayNotes);
|
||||||
if (myNotes) {
|
if (myNotes) {
|
||||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||||
@@ -100,7 +193,9 @@ export function MurView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (hist) setHistory(hist);
|
if (hist) setHistory(hist);
|
||||||
}, [isJudge]);
|
if (counts) setReactionCounts(buildReactionCounts(counts));
|
||||||
|
if (mine) setMyReactions(buildMyReactions(mine));
|
||||||
|
}, [isJudge, currentUserId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -112,6 +207,7 @@ export function MurView({
|
|||||||
channel = supabase
|
channel = supabase
|
||||||
.channel("wall-notes-changes")
|
.channel("wall-notes-changes")
|
||||||
.on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch)
|
.on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch)
|
||||||
|
.on("postgres_changes", { event: "*", schema: "public", table: "wall_note_reactions" }, refetch)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,6 +217,37 @@ export function MurView({
|
|||||||
};
|
};
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
|
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
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) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -133,7 +260,33 @@ export function MurView({
|
|||||||
|
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
const supabase = createClient();
|
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);
|
setPosting(false);
|
||||||
|
|
||||||
if (rpcError) {
|
if (rpcError) {
|
||||||
@@ -149,9 +302,34 @@ export function MurView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setText("");
|
setText("");
|
||||||
|
clearImage();
|
||||||
refetch();
|
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 wallFull = notes.length >= MAX_NOTES_PER_DAY;
|
||||||
|
|
||||||
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
|
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
|
||||||
@@ -172,7 +350,13 @@ export function MurView({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<NoteCard key={note.id} note={note} />
|
<NoteCard
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
reactionCounts={reactionCounts[note.id] ?? {}}
|
||||||
|
myReactions={myReactions[note.id] ?? new Set()}
|
||||||
|
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,6 +367,15 @@ export function MurView({
|
|||||||
{myNoteToday ? (
|
{myNoteToday ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-olive">Ta note d'aujourd'hui est publiée.</p>
|
<p className="text-sm text-olive">Ta note d'aujourd'hui est publiée.</p>
|
||||||
|
{myNoteToday.image_url && (
|
||||||
|
<Image
|
||||||
|
src={myNoteToday.image_url}
|
||||||
|
alt=""
|
||||||
|
width={300}
|
||||||
|
height={200}
|
||||||
|
className="mt-2 max-h-40 w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
|
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
|
||||||
{myNoteToday.text}
|
{myNoteToday.text}
|
||||||
</p>
|
</p>
|
||||||
@@ -203,6 +396,35 @@ export function MurView({
|
|||||||
<p className="text-right text-[0.65rem] text-text-mut">
|
<p className="text-right text-[0.65rem] text-text-mut">
|
||||||
{text.length}/{MAX_LENGTH}
|
{text.length}/{MAX_LENGTH}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="relative w-32">
|
||||||
|
<Image
|
||||||
|
src={imagePreview}
|
||||||
|
alt=""
|
||||||
|
width={128}
|
||||||
|
height={96}
|
||||||
|
unoptimized
|
||||||
|
className="h-24 w-32 rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearImage}
|
||||||
|
className="absolute -top-2 -right-2 rounded-full bg-ink-2 p-1 text-marble"
|
||||||
|
aria-label="Retirer l'image"
|
||||||
|
>
|
||||||
|
<IconClose className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10">
|
||||||
|
<IconCamera className="h-4 w-4" />
|
||||||
|
Ajouter une image (optionnel)
|
||||||
|
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{imageError && <p className="text-xs text-oxblood">{imageError}</p>}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm text-oxblood">
|
<p role="alert" className="text-sm text-oxblood">
|
||||||
{error}
|
{error}
|
||||||
@@ -257,6 +479,15 @@ export function MurView({
|
|||||||
{note.profiles?.pseudo ?? "un Citoyen"} — {formatDateTime(note.created_at)}
|
{note.profiles?.pseudo ?? "un Citoyen"} — {formatDateTime(note.created_at)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-text-marble">{note.text}</p>
|
<p className="text-text-marble">{note.text}</p>
|
||||||
|
{note.image_url && (
|
||||||
|
<Image
|
||||||
|
src={note.image_url}
|
||||||
|
alt=""
|
||||||
|
width={200}
|
||||||
|
height={150}
|
||||||
|
className="mt-1 max-h-32 rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+15
-3
@@ -5,6 +5,7 @@ import { MurView } from "./mur-view";
|
|||||||
export type WallNoteRow = {
|
export type WallNoteRow = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
image_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,10 +13,14 @@ export type WallHistoryRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
image_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
profiles: { pseudo: string; avatar_url: string | null } | null;
|
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 {
|
function isToday(iso: string): boolean {
|
||||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||||
const noteDate = new Date(iso).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();
|
.single();
|
||||||
const isJudge = callerProfile?.role === "judge";
|
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;
|
let myNoteToday: WallNoteRow | null = null;
|
||||||
if (!isJudge) {
|
if (!isJudge) {
|
||||||
const { data: myNotes } = await supabase
|
const { data: myNotes } = await supabase
|
||||||
.from("wall_notes")
|
.from("wall_notes")
|
||||||
.select("id, text, created_at")
|
.select("id, text, image_url, created_at")
|
||||||
.eq("user_id", user.id)
|
.eq("user_id", user.id)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
|
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
|
||||||
@@ -55,7 +64,7 @@ export default async function MurPage() {
|
|||||||
if (isJudge) {
|
if (isJudge) {
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from("wall_notes")
|
.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 })
|
.order("created_at", { ascending: false })
|
||||||
.returns<WallHistoryRow[]>();
|
.returns<WallHistoryRow[]>();
|
||||||
history = data ?? [];
|
history = data ?? [];
|
||||||
@@ -71,9 +80,12 @@ export default async function MurPage() {
|
|||||||
</div>
|
</div>
|
||||||
<MurView
|
<MurView
|
||||||
isJudge={isJudge}
|
isJudge={isJudge}
|
||||||
|
currentUserId={user.id}
|
||||||
initialNotes={todayNotes ?? []}
|
initialNotes={todayNotes ?? []}
|
||||||
initialMyNoteToday={myNoteToday}
|
initialMyNoteToday={myNoteToday}
|
||||||
initialHistory={history}
|
initialHistory={history}
|
||||||
|
initialReactionCounts={reactionCounts ?? []}
|
||||||
|
initialMyReactions={myReactions ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<Blob> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+145
-8
@@ -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
|
-- 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
|
-- seulement pour le classement. L'anonymat n'est qu'à moitié réel : les
|
||||||
-- Archontes voient l'historique complet avec l'auteur de chaque note.
|
-- 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 (
|
create table if not exists public.wall_notes (
|
||||||
id uuid primary key default gen_random_uuid(),
|
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()
|
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;
|
alter table public.wall_notes enable row level security;
|
||||||
revoke insert, update, delete on public.wall_notes from authenticated, anon;
|
revoke insert, update, delete on public.wall_notes from authenticated, anon;
|
||||||
grant select on public.wall_notes to authenticated;
|
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
|
-- 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
|
-- 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
|
-- 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()
|
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
|
language sql
|
||||||
security definer
|
security definer
|
||||||
set search_path = public
|
set search_path = public
|
||||||
as $$
|
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
|
from public.wall_notes w
|
||||||
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
|
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
|
||||||
order by w.created_at asc;
|
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
|
-- 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
|
-- personnes postent la même seconde alors qu'il reste 1 place (double
|
||||||
-- insertion à 11) — acceptée sciemment, comme d'autres arbitrages similaires
|
-- insertion à 11) — acceptée sciemment, comme d'autres arbitrages similaires
|
||||||
-- déjà faits dans le projet pour un groupe de ~12 amis.
|
-- déjà faits dans le projet pour un groupe de ~12 amis. La signature change
|
||||||
create or replace function public.post_wall_note(p_text text)
|
-- (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
|
returns public.wall_notes
|
||||||
language plpgsql
|
language plpgsql
|
||||||
security definer
|
security definer
|
||||||
@@ -1096,6 +1110,7 @@ declare
|
|||||||
v_today date := (now() at time zone 'Europe/Paris')::date;
|
v_today date := (now() at time zone 'Europe/Paris')::date;
|
||||||
v_already_posted boolean;
|
v_already_posted boolean;
|
||||||
v_count_today int;
|
v_count_today int;
|
||||||
|
v_image_url text := nullif(trim(coalesce(p_image_url, '')), '');
|
||||||
v_row public.wall_notes;
|
v_row public.wall_notes;
|
||||||
begin
|
begin
|
||||||
if auth.uid() is null then
|
if auth.uid() is null then
|
||||||
@@ -1114,6 +1129,13 @@ begin
|
|||||||
raise exception 'note trop longue';
|
raise exception 'note trop longue';
|
||||||
end if;
|
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 exists (
|
||||||
select 1 from public.wall_notes
|
select 1 from public.wall_notes
|
||||||
where user_id = auth.uid()
|
where user_id = auth.uid()
|
||||||
@@ -1130,15 +1152,130 @@ begin
|
|||||||
raise exception 'wall full today';
|
raise exception 'wall full today';
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
insert into public.wall_notes (user_id, text)
|
insert into public.wall_notes (user_id, text, image_url)
|
||||||
values (auth.uid(), trim(p_text))
|
values (auth.uid(), trim(p_text), v_image_url)
|
||||||
returning * into v_row;
|
returning * into v_row;
|
||||||
|
|
||||||
return v_row;
|
return v_row;
|
||||||
end;
|
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 -------------------------------------------------------
|
-- 15. L'Urne de l'Agora -------------------------------------------------------
|
||||||
-- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour
|
-- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour
|
||||||
|
|||||||
Reference in New Issue
Block a user