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.
335 lines
11 KiB
PL/PgSQL
335 lines
11 KiB
PL/PgSQL
-- É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;
|