Initial commit: WaMarket MVP

Marketplace inversée (Next.js 16 App Router + Supabase Cloud) : auth,
schéma + RLS, publication de besoins avec upload d'images, feed avec
filtres, offres, acceptation via RPC, messagerie temps réel,
dashboards, et Dockerfile pour déploiement Coolify.
This commit is contained in:
2026-07-30 14:33:17 +00:00
commit af81124d48
82 changed files with 16824 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
-- Étape 2 (auth) — table profiles + trigger de création automatique.
-- À coller dans Supabase Studio > SQL Editor et exécuter une fois.
create table if not exists public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
avatar_url text,
city text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table public.profiles enable row level security;
drop policy if exists "profiles_select_authenticated" on public.profiles;
create policy "profiles_select_authenticated"
on public.profiles for select
to authenticated
using (true);
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using (auth.uid() = id)
with check (auth.uid() = id);
-- Crée automatiquement une ligne profiles à chaque inscription (auth.users).
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, full_name)
values (new.id, new.raw_user_meta_data ->> 'full_name');
return new;
end;
$$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
+334
View File
@@ -0,0 +1,334 @@
-- Étape 3 — schéma complet : catégories, besoins, offres, conversations, messages,
-- storage, RLS, et la fonction accept_offer(). Appliqué via le MCP Supabase.
-- ============ Catégories ============
create table if not exists public.categories (
id uuid primary key default gen_random_uuid(),
name text not null unique,
slug text not null unique,
icon text,
created_at timestamptz not null default now()
);
alter table public.categories enable row level security;
drop policy if exists "categories_select_all" on public.categories;
create policy "categories_select_all"
on public.categories for select
using (true);
insert into public.categories (name, slug) values
('Bricolage', 'bricolage'),
('Jardinage', 'jardinage'),
('Ménage', 'menage'),
('Déménagement', 'demenagement'),
('Informatique', 'informatique'),
('Cours particuliers', 'cours-particuliers'),
('Beauté & bien-être', 'beaute-bien-etre'),
('Événementiel', 'evenementiel'),
('Automobile', 'automobile'),
('Autre', 'autre')
on conflict (slug) do nothing;
-- ============ Besoins (needs) ============
create table if not exists public.needs (
id uuid primary key default gen_random_uuid(),
author_id uuid not null references public.profiles (id) on delete cascade,
category_id uuid references public.categories (id),
title text not null,
description text not null,
city text not null,
region text,
budget_min numeric,
budget_max numeric,
status text not null default 'open' check (status in ('open', 'closed', 'fulfilled')),
accepted_offer_id uuid,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.need_images (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
storage_path text not null,
position int not null default 0,
created_at timestamptz not null default now()
);
-- ============ Offres ============
create table if not exists public.offers (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
merchant_id uuid not null references public.profiles (id) on delete cascade,
title text not null,
description text not null,
price numeric not null,
status text not null default 'pending' check (status in ('pending', 'accepted', 'rejected', 'withdrawn')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (need_id, merchant_id)
);
create table if not exists public.offer_images (
id uuid primary key default gen_random_uuid(),
offer_id uuid not null references public.offers (id) on delete cascade,
storage_path text not null,
position int not null default 0
);
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'needs_accepted_offer_fk'
) then
alter table public.needs
add constraint needs_accepted_offer_fk
foreign key (accepted_offer_id) references public.offers (id);
end if;
end;
$$;
-- ============ Conversations & messages ============
create table if not exists public.conversations (
id uuid primary key default gen_random_uuid(),
need_id uuid not null references public.needs (id) on delete cascade,
offer_id uuid not null unique references public.offers (id) on delete cascade,
author_id uuid not null references public.profiles (id),
merchant_id uuid not null references public.profiles (id),
created_at timestamptz not null default now()
);
create table if not exists public.messages (
id uuid primary key default gen_random_uuid(),
conversation_id uuid not null references public.conversations (id) on delete cascade,
sender_id uuid not null references public.profiles (id),
body text not null,
created_at timestamptz not null default now()
);
-- ============ RLS ============
alter table public.needs enable row level security;
alter table public.need_images enable row level security;
alter table public.offers enable row level security;
alter table public.offer_images enable row level security;
alter table public.conversations enable row level security;
alter table public.messages enable row level security;
-- needs: lecture publique si open, ou toujours par l'auteur ; écriture par l'auteur uniquement
drop policy if exists "needs_select" on public.needs;
create policy "needs_select"
on public.needs for select
using (status = 'open' or auth.uid() = author_id);
drop policy if exists "needs_insert_own" on public.needs;
create policy "needs_insert_own"
on public.needs for insert
to authenticated
with check (auth.uid() = author_id);
drop policy if exists "needs_update_own" on public.needs;
create policy "needs_update_own"
on public.needs for update
to authenticated
using (auth.uid() = author_id)
with check (auth.uid() = author_id);
-- need_images: miroir de la visibilité du besoin parent
drop policy if exists "need_images_select" on public.need_images;
create policy "need_images_select"
on public.need_images for select
using (
exists (
select 1 from public.needs n
where n.id = need_id and (n.status = 'open' or auth.uid() = n.author_id)
)
);
drop policy if exists "need_images_insert_own" on public.need_images;
create policy "need_images_insert_own"
on public.need_images for insert
to authenticated
with check (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
drop policy if exists "need_images_delete_own" on public.need_images;
create policy "need_images_delete_own"
on public.need_images for delete
to authenticated
using (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
-- offers: visibles uniquement par le marchand auteur et l'auteur du besoin (jamais entre marchands concurrents)
drop policy if exists "offers_select" on public.offers;
create policy "offers_select"
on public.offers for select
to authenticated
using (
auth.uid() = merchant_id
or exists (select 1 from public.needs n where n.id = need_id and n.author_id = auth.uid())
);
drop policy if exists "offers_insert" on public.offers;
create policy "offers_insert"
on public.offers for insert
to authenticated
with check (
merchant_id = auth.uid()
and exists (
select 1 from public.needs n
where n.id = need_id and n.status = 'open' and n.author_id <> auth.uid()
)
);
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using (auth.uid() = merchant_id and status = 'pending')
with check (auth.uid() = merchant_id);
-- offer_images: miroir de la visibilité de l'offre parente
drop policy if exists "offer_images_select" on public.offer_images;
create policy "offer_images_select"
on public.offer_images for select
to authenticated
using (
exists (
select 1 from public.offers o
where o.id = offer_id
and (
o.merchant_id = auth.uid()
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = auth.uid())
)
)
);
drop policy if exists "offer_images_insert_own" on public.offer_images;
create policy "offer_images_insert_own"
on public.offer_images for insert
to authenticated
with check (
exists (select 1 from public.offers o where o.id = offer_id and o.merchant_id = auth.uid())
);
-- conversations: lecture par les deux participants ; aucune insertion côté client
drop policy if exists "conversations_select" on public.conversations;
create policy "conversations_select"
on public.conversations for select
to authenticated
using (auth.uid() = author_id or auth.uid() = merchant_id);
-- messages: lecture/écriture réservées aux participants de la conversation
drop policy if exists "messages_select" on public.messages;
create policy "messages_select"
on public.messages for select
to authenticated
using (
exists (
select 1 from public.conversations c
where c.id = conversation_id and (auth.uid() = c.author_id or auth.uid() = c.merchant_id)
)
);
drop policy if exists "messages_insert" on public.messages;
create policy "messages_insert"
on public.messages for insert
to authenticated
with check (
sender_id = auth.uid()
and exists (
select 1 from public.conversations c
where c.id = conversation_id and (auth.uid() = c.author_id or auth.uid() = c.merchant_id)
)
);
-- ============ Fonction accept_offer ============
create or replace function public.accept_offer(p_offer_id uuid)
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_need_id uuid;
v_author_id uuid;
v_merchant_id uuid;
v_conversation_id uuid;
begin
select o.need_id, n.author_id, o.merchant_id
into v_need_id, v_author_id, v_merchant_id
from public.offers o
join public.needs n on n.id = o.need_id
where o.id = p_offer_id and o.status = 'pending' and n.status = 'open';
if v_need_id is null then
raise exception 'Offer not eligible for acceptance';
end if;
if auth.uid() <> v_author_id then
raise exception 'Not authorized';
end if;
update public.offers set status = 'rejected', updated_at = now()
where need_id = v_need_id and id <> p_offer_id and status = 'pending';
update public.offers set status = 'accepted', updated_at = now()
where id = p_offer_id;
update public.needs set status = 'fulfilled', accepted_offer_id = p_offer_id, updated_at = now()
where id = v_need_id;
insert into public.conversations (need_id, offer_id, author_id, merchant_id)
values (v_need_id, p_offer_id, v_author_id, v_merchant_id)
returning id into v_conversation_id;
return v_conversation_id;
end;
$$;
-- ============ Storage buckets ============
insert into storage.buckets (id, name, public)
values ('need-images', 'need-images', true)
on conflict (id) do nothing;
insert into storage.buckets (id, name, public)
values ('offer-images', 'offer-images', false)
on conflict (id) do nothing;
drop policy if exists "need_images_bucket_read" on storage.objects;
create policy "need_images_bucket_read"
on storage.objects for select
using (bucket_id = 'need-images');
-- Convention de chemin : {auth.uid()}/{needId}/{filename} — chacun ne peut
-- écrire que dans son propre dossier.
drop policy if exists "need_images_bucket_write" on storage.objects;
create policy "need_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'need-images'
and (storage.foldername(name))[1] = auth.uid()::text
);
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (bucket_id = 'offer-images');
-- Convention de chemin : {auth.uid()}/{offerId}/{filename}.
drop policy if exists "offer_images_bucket_write" on storage.objects;
create policy "offer_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'offer-images'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- ============ Realtime ============
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.conversations;
+33
View File
@@ -0,0 +1,33 @@
-- Corrections suite au linter de sécurité Supabase (get_advisors) après 0002.
-- 1. Bucket public : pas besoin d'une policy SELECT (elle permet le listing
-- complet du bucket, superflu — l'URL publique fonctionne sans RLS).
drop policy if exists "need_images_bucket_read" on storage.objects;
-- 2. handle_new_user() ne doit être invocable que par le trigger, pas en RPC public.
revoke execute on function public.handle_new_user() from public, anon, authenticated;
-- 3. accept_offer() : anon n'a aucune raison de l'appeler (auth.uid() sera
-- toujours null pour lui, mais autant réduire la surface d'API exposée).
revoke execute on function public.accept_offer(uuid) from anon;
-- 4. offer-images : resserrer la lecture aux seuls participants (marchand
-- auteur de l'offre, ou auteur du besoin concerné) au lieu de "tout
-- utilisateur connecté".
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (
bucket_id = 'offer-images'
and exists (
select 1
from public.offer_images oi
join public.offers o on o.id = oi.offer_id
where oi.storage_path = storage.objects.name
and (
o.merchant_id = auth.uid()
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = auth.uid())
)
)
);
+192
View File
@@ -0,0 +1,192 @@
-- Corrections suite au linter de performance Supabase (get_advisors) :
-- 1. Wrap auth.uid() en (select auth.uid()) dans toutes les policies RLS
-- (évite une réévaluation par ligne — recommandation officielle Supabase).
-- 2. Index sur toutes les clés étrangères signalées.
-- ---- profiles ----
drop policy if exists "profiles_update_own" on public.profiles;
create policy "profiles_update_own"
on public.profiles for update
to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id);
-- ---- needs ----
drop policy if exists "needs_select" on public.needs;
create policy "needs_select"
on public.needs for select
using (status = 'open' or (select auth.uid()) = author_id);
drop policy if exists "needs_insert_own" on public.needs;
create policy "needs_insert_own"
on public.needs for insert
to authenticated
with check ((select auth.uid()) = author_id);
drop policy if exists "needs_update_own" on public.needs;
create policy "needs_update_own"
on public.needs for update
to authenticated
using ((select auth.uid()) = author_id)
with check ((select auth.uid()) = author_id);
-- ---- need_images ----
drop policy if exists "need_images_select" on public.need_images;
create policy "need_images_select"
on public.need_images for select
using (
exists (
select 1 from public.needs n
where n.id = need_id and (n.status = 'open' or (select auth.uid()) = n.author_id)
)
);
drop policy if exists "need_images_insert_own" on public.need_images;
create policy "need_images_insert_own"
on public.need_images for insert
to authenticated
with check (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
drop policy if exists "need_images_delete_own" on public.need_images;
create policy "need_images_delete_own"
on public.need_images for delete
to authenticated
using (
exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
-- ---- offers ----
drop policy if exists "offers_select" on public.offers;
create policy "offers_select"
on public.offers for select
to authenticated
using (
(select auth.uid()) = merchant_id
or exists (select 1 from public.needs n where n.id = need_id and n.author_id = (select auth.uid()))
);
drop policy if exists "offers_insert" on public.offers;
create policy "offers_insert"
on public.offers for insert
to authenticated
with check (
merchant_id = (select auth.uid())
and exists (
select 1 from public.needs n
where n.id = need_id and n.status = 'open' and n.author_id <> (select auth.uid())
)
);
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using ((select auth.uid()) = merchant_id and status = 'pending')
with check ((select auth.uid()) = merchant_id);
-- ---- offer_images ----
drop policy if exists "offer_images_select" on public.offer_images;
create policy "offer_images_select"
on public.offer_images for select
to authenticated
using (
exists (
select 1 from public.offers o
where o.id = offer_id
and (
o.merchant_id = (select auth.uid())
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = (select auth.uid()))
)
)
);
drop policy if exists "offer_images_insert_own" on public.offer_images;
create policy "offer_images_insert_own"
on public.offer_images for insert
to authenticated
with check (
exists (select 1 from public.offers o where o.id = offer_id and o.merchant_id = (select auth.uid()))
);
-- ---- conversations ----
drop policy if exists "conversations_select" on public.conversations;
create policy "conversations_select"
on public.conversations for select
to authenticated
using ((select auth.uid()) = author_id or (select auth.uid()) = merchant_id);
-- ---- messages ----
drop policy if exists "messages_select" on public.messages;
create policy "messages_select"
on public.messages for select
to authenticated
using (
exists (
select 1 from public.conversations c
where c.id = conversation_id and ((select auth.uid()) = c.author_id or (select auth.uid()) = c.merchant_id)
)
);
drop policy if exists "messages_insert" on public.messages;
create policy "messages_insert"
on public.messages for insert
to authenticated
with check (
sender_id = (select auth.uid())
and exists (
select 1 from public.conversations c
where c.id = conversation_id and ((select auth.uid()) = c.author_id or (select auth.uid()) = c.merchant_id)
)
);
-- ---- storage.objects (offer-images) ----
drop policy if exists "offer_images_bucket_read" on storage.objects;
create policy "offer_images_bucket_read"
on storage.objects for select
to authenticated
using (
bucket_id = 'offer-images'
and exists (
select 1
from public.offer_images oi
join public.offers o on o.id = oi.offer_id
where oi.storage_path = storage.objects.name
and (
o.merchant_id = (select auth.uid())
or exists (select 1 from public.needs n where n.id = o.need_id and n.author_id = (select auth.uid()))
)
)
);
drop policy if exists "need_images_bucket_write" on storage.objects;
create policy "need_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'need-images'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
drop policy if exists "offer_images_bucket_write" on storage.objects;
create policy "offer_images_bucket_write"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'offer-images'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- ---- Index sur les clés étrangères ----
create index if not exists idx_needs_author_id on public.needs (author_id);
create index if not exists idx_needs_category_id on public.needs (category_id);
create index if not exists idx_needs_accepted_offer_id on public.needs (accepted_offer_id);
create index if not exists idx_need_images_need_id on public.need_images (need_id);
create index if not exists idx_offers_merchant_id on public.offers (merchant_id);
create index if not exists idx_offer_images_offer_id on public.offer_images (offer_id);
create index if not exists idx_conversations_need_id on public.conversations (need_id);
create index if not exists idx_conversations_author_id on public.conversations (author_id);
create index if not exists idx_conversations_merchant_id on public.conversations (merchant_id);
create index if not exists idx_messages_conversation_id on public.messages (conversation_id);
create index if not exists idx_messages_sender_id on public.messages (sender_id);
@@ -0,0 +1,13 @@
-- Faille corrigée : la policy offers_update_own_pending ne limitait pas la
-- valeur cible de `status`, permettant à un marchand de passer directement sa
-- propre offre à 'accepted' via un simple PATCH, en contournant accept_offer().
-- Désormais, un marchand ne peut que rester en 'pending' (édition) ou passer
-- en 'withdrawn' (retrait volontaire) ; 'accepted'/'rejected' restent
-- exclusivement gérés par la fonction security definer accept_offer().
drop policy if exists "offers_update_own_pending" on public.offers;
create policy "offers_update_own_pending"
on public.offers for update
to authenticated
using ((select auth.uid()) = merchant_id and status = 'pending')
with check ((select auth.uid()) = merchant_id and status in ('pending', 'withdrawn'));