-- É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;