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:
@@ -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;
|
||||
Reference in New Issue
Block a user