-- ============================================================ -- ShootTracker — Schéma Supabase complet -- Migration 001 — Schéma initial -- ============================================================ -- Extension UUID create extension if not exists "uuid-ossp"; -- ───────────────────────────────────────────────────────────── -- TABLES -- ───────────────────────────────────────────────────────────── -- Profils utilisateurs (étend auth.users) create table if not exists public.profiles ( id uuid references auth.users on delete cascade primary key, first_name text, last_name text, club text, disciplines text[] default '{}', avatar_url text, created_at timestamptz default now(), updated_at timestamptz default now() ); -- Armes (coffre virtuel) create table if not exists public.weapons ( id uuid default gen_random_uuid() primary key, user_id uuid references auth.users on delete cascade not null, name text not null, nickname text, type text not null check (type in ('pistolet','carabine','fusil','arc','arbalète','autre')), caliber text, brand text, model text, serial_number text, photo_url text, notes text, is_archived boolean default false, created_at timestamptz default now(), updated_at timestamptz default now() ); -- Séances d'entraînement create table if not exists public.training_sessions ( id uuid default gen_random_uuid() primary key, user_id uuid references auth.users on delete cascade not null, weapon_id uuid references public.weapons on delete set null, session_date timestamptz not null default now(), location text, target_type text not null check (target_type in ('issf','silhouette','libre')), distance_m integer, notes_start text, notes_end text, total_shots_declared integer default 0, total_shots_detected integer default 0, total_score integer default 0, avg_score real default 0, best_series_score integer default 0, ai_detection_rate real default 0, avg_dispersion real, is_completed boolean default false, created_at timestamptz default now(), updated_at timestamptz default now() ); -- Séries de tirs (dans une séance) create table if not exists public.series ( id uuid default gen_random_uuid() primary key, session_id uuid references public.training_sessions on delete cascade not null, user_id uuid references auth.users on delete cascade not null, series_number integer not null, shots_declared integer not null default 0, shots_detected integer default 0, shots_manual integer default 0, photo_url text, annotated_photo_url text, score_zone integer default 0, score_groupement integer default 0, score_total integer default 0, dispersion_radius real, center_x real, center_y real, ai_data jsonb, created_at timestamptz default now() ); -- Impacts individuels create table if not exists public.impacts ( id uuid default gen_random_uuid() primary key, series_id uuid references public.series on delete cascade not null, user_id uuid references auth.users on delete cascade not null, x real not null, y real not null, zone integer, points integer default 0, is_manual boolean default false, created_at timestamptz default now() ); -- Invitations create table if not exists public.invitations ( id uuid default gen_random_uuid() primary key, inviter_id uuid references auth.users on delete cascade not null, email text, token text unique not null default encode(gen_random_bytes(32), 'hex'), is_used boolean default false, used_by uuid references auth.users on delete set null, created_at timestamptz default now(), expires_at timestamptz default (now() + interval '7 days') ); -- Lieux mémorisés create table if not exists public.user_locations ( id uuid default gen_random_uuid() primary key, user_id uuid references auth.users on delete cascade not null, name text not null, used_count integer default 1, last_used_at timestamptz default now(), unique (user_id, name) ); -- ───────────────────────────────────────────────────────────── -- INDEXES -- ───────────────────────────────────────────────────────────── create index if not exists weapons_user_id_idx on public.weapons(user_id); create index if not exists weapons_type_idx on public.weapons(type); create index if not exists weapons_archived_idx on public.weapons(is_archived); create index if not exists sessions_user_id_idx on public.training_sessions(user_id); create index if not exists sessions_weapon_id_idx on public.training_sessions(weapon_id); create index if not exists sessions_date_idx on public.training_sessions(session_date desc); create index if not exists sessions_completed_idx on public.training_sessions(is_completed); create index if not exists series_session_id_idx on public.series(session_id); create index if not exists series_user_id_idx on public.series(user_id); create index if not exists impacts_series_id_idx on public.impacts(series_id); create index if not exists impacts_user_id_idx on public.impacts(user_id); create index if not exists invitations_token_idx on public.invitations(token); create index if not exists invitations_inviter_idx on public.invitations(inviter_id); create index if not exists locations_user_id_idx on public.user_locations(user_id); -- ───────────────────────────────────────────────────────────── -- ROW LEVEL SECURITY -- ───────────────────────────────────────────────────────────── alter table public.profiles enable row level security; alter table public.weapons enable row level security; alter table public.training_sessions enable row level security; alter table public.series enable row level security; alter table public.impacts enable row level security; alter table public.invitations enable row level security; alter table public.user_locations enable row level security; -- Profiles create policy "profiles_own" on public.profiles for all using (auth.uid() = id); -- Weapons create policy "weapons_own" on public.weapons for all using (auth.uid() = user_id); -- Training sessions create policy "sessions_own" on public.training_sessions for all using (auth.uid() = user_id); -- Series create policy "series_own" on public.series for all using (auth.uid() = user_id); -- Impacts create policy "impacts_own" on public.impacts for all using (auth.uid() = user_id); -- Invitations — le créateur voit ses invitations ; les non-connectés peuvent vérifier un token create policy "invitations_own_select" on public.invitations for select using (auth.uid() = inviter_id or not is_used); create policy "invitations_own_insert" on public.invitations for insert with check (auth.uid() = inviter_id); create policy "invitations_own_update" on public.invitations for update using (auth.uid() = inviter_id or (auth.uid() is not null and not is_used)); -- User locations create policy "locations_own" on public.user_locations for all using (auth.uid() = user_id); -- ───────────────────────────────────────────────────────────── -- FUNCTIONS & TRIGGERS -- ───────────────────────────────────────────────────────────── -- Crée automatiquement un profil à l'inscription 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, first_name, last_name) values ( new.id, coalesce(new.raw_user_meta_data->>'first_name', ''), coalesce(new.raw_user_meta_data->>'last_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 procedure public.handle_new_user(); -- Met à jour updated_at automatiquement create or replace function public.set_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; create trigger weapons_updated_at before update on public.weapons for each row execute procedure public.set_updated_at(); create trigger sessions_updated_at before update on public.training_sessions for each row execute procedure public.set_updated_at(); create trigger profiles_updated_at before update on public.profiles for each row execute procedure public.set_updated_at(); -- ───────────────────────────────────────────────────────────── -- STORAGE BUCKETS (à exécuter dans Supabase Dashboard → Storage) -- ───────────────────────────────────────────────────────────── -- NB : ces commandes SQL ne fonctionnent pas directement depuis le SQL editor -- Créer manuellement dans Dashboard → Storage : -- bucket "avatars" → privé, taille max 5 MB -- bucket "weapon-photos" → privé, taille max 10 MB -- bucket "target-photos" → privé, taille max 20 MB (photos haute res) -- -- Puis appliquer ces policies de storage : insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('avatars', 'avatars', false, 5242880, array['image/jpeg','image/png','image/webp']), ('weapon-photos', 'weapon-photos', false, 10485760, array['image/jpeg','image/png','image/webp']), ('target-photos', 'target-photos', false, 20971520, array['image/jpeg','image/png','image/webp']) on conflict (id) do nothing; -- Policies storage : chaque user n'accède qu'à son propre dossier create policy "avatars_own" on storage.objects for all using (bucket_id = 'avatars' and auth.uid()::text = (storage.foldername(name))[1]); create policy "weapon_photos_own" on storage.objects for all using (bucket_id = 'weapon-photos' and auth.uid()::text = (storage.foldername(name))[1]); create policy "target_photos_own" on storage.objects for all using (bucket_id = 'target-photos' and auth.uid()::text = (storage.foldername(name))[1]);