-- ===================================================================== -- GameDev Tracker — Schéma complet (Supabase / PostgreSQL) -- -- NOUVELLE base : exécute CE fichier, une seule fois. -- Base EXISTANTE : exécute plutôt « update_all.sql » (ne perd rien). -- -- Où ? Supabase > SQL Editor > New query > coller > Run. -- ===================================================================== -- ---------- Types énumérés ------------------------------------------- do $$ begin create type member_role as enum ('lead','programmer','artist','game_designer','sound','writer','qa','other'); exception when duplicate_object then null; end $$; do $$ begin create type task_status as enum ('todo','in_progress','review','done'); exception when duplicate_object then null; end $$; do $$ begin create type priority_level as enum ('low','medium','high','urgent'); exception when duplicate_object then null; end $$; do $$ begin create type bug_severity as enum ('low','medium','high','critical'); exception when duplicate_object then null; end $$; do $$ begin create type bug_status as enum ('open','in_progress','resolved','closed'); exception when duplicate_object then null; end $$; -- ===================================================================== -- TABLES -- ===================================================================== -- ---------- Profils (étend auth.users) ------------------------------- -- `roles` est la SEULE source de vérité pour les pôles d'un membre. create table if not exists public.profiles ( id uuid primary key references auth.users(id) on delete cascade, full_name text not null default 'Membre', roles member_role[] not null default '{}', avatar_color text not null default '#7c5cff', avatar_url text, is_admin boolean not null default false, onboarded boolean not null default false, created_at timestamptz not null default now() ); -- ---------- Projets -------------------------------------------------- create table if not exists public.projects ( id uuid primary key default gen_random_uuid(), name text not null, description text, created_by uuid references public.profiles(id) on delete set null, created_at timestamptz not null default now() ); -- ---------- Tâches --------------------------------------------------- -- Une tâche est confiée à un ou plusieurs PÔLES (poles) et assignée -- nommément à une ou plusieurs PERSONNES (assignee_ids). create table if not exists public.tasks ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, title text not null, description text, status task_status not null default 'todo', priority priority_level not null default 'medium', poles member_role[] not null default '{}', assignee_ids uuid[] not null default '{}', checklist jsonb not null default '[]'::jsonb, -- [{id, text, done}] tags text[] not null default '{}', due_date date, position double precision not null default 0, created_by uuid references public.profiles(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- ---------- Bugs ----------------------------------------------------- -- Même modèle que les tâches (pôles + assignés multiples). La SÉVÉRITÉ -- est le seul axe d'importance : la colonne `priority` faisait doublon -- et n'est plus exposée dans l'interface. create table if not exists public.bugs ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, title text not null, description text, severity bug_severity not null default 'medium', status bug_status not null default 'open', priority priority_level not null default 'medium', poles member_role[] not null default '{}', assignee_ids uuid[] not null default '{}', reporter_id uuid references public.profiles(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- ---------- Journal du temps (tâche OU activité libre) --------------- create table if not exists public.time_logs ( id uuid primary key default gen_random_uuid(), project_id uuid references public.projects(id) on delete cascade, task_id uuid references public.tasks(id) on delete cascade, bug_id uuid references public.bugs(id) on delete cascade, label text, -- si ni tâche ni bug user_id uuid references public.profiles(id) on delete set null, minutes integer not null check (minutes > 0), note text, logged_at date not null default current_date, created_at timestamptz not null default now(), -- Une entrée porte sur une tâche OU un bug OU rien : jamais les deux. constraint time_logs_one_target check (not (task_id is not null and bug_id is not null)) ); -- ---------- Commentaires (tâches et bugs) ---------------------------- create table if not exists public.comments ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, entity_kind text not null check (entity_kind in ('task','bug')), entity_id uuid not null, author_id uuid references public.profiles(id) on delete set null, body text not null check (length(btrim(body)) > 0), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- ---------- Pièces jointes (captures d'écran, logs) ------------------ create table if not exists public.attachments ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, entity_kind text not null check (entity_kind in ('task','bug')), entity_id uuid not null, uploader_id uuid references public.profiles(id) on delete set null, name text not null, path text not null, size bigint not null default 0, mime text, created_at timestamptz not null default now() ); -- ---------- Documentation (fiches créées ou importées) ---------------- -- Le contenu est du Markdown : format pivot, lisible et cherchable. Un -- .docx importé est converti en Markdown au moment de l'import. create table if not exists public.documents ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, title text not null, content text not null default '', tags text[] not null default '{}', source_name text, created_by uuid references public.profiles(id) on delete set null, updated_by uuid references public.profiles(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- ---------- Notifications -------------------------------------------- create table if not exists public.notifications ( id uuid primary key default gen_random_uuid(), user_id uuid not null references public.profiles(id) on delete cascade, project_id uuid references public.projects(id) on delete cascade, kind text not null, -- 'task' | 'bug' | 'comment' entity_kind text not null default 'task', -- ce qu'on ouvre au clic entity_id uuid, title text not null, body text, pole member_role, read boolean not null default false, created_at timestamptz not null default now() ); -- ===================================================================== -- INDEX -- ===================================================================== create index if not exists idx_tasks_project on public.tasks(project_id); create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position); create index if not exists idx_bugs_project on public.bugs(project_id); create index if not exists idx_bugs_project_status on public.bugs(project_id, status); create index if not exists idx_time_logs_task on public.time_logs(task_id); create index if not exists idx_time_logs_bug on public.time_logs(bug_id); create index if not exists idx_time_logs_project on public.time_logs(project_id); create index if not exists idx_time_logs_user on public.time_logs(user_id); create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at); create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id); create index if not exists idx_comments_project on public.comments(project_id); create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id); create index if not exists idx_attachments_project on public.attachments(project_id); create index if not exists idx_documents_project on public.documents(project_id, updated_at desc); create index if not exists idx_notifications_user on public.notifications(user_id); create index if not exists idx_notifications_user_unread on public.notifications(user_id, read, created_at desc); -- ===================================================================== -- FONCTIONS & TRIGGERS -- ===================================================================== -- ---------- Maj automatique de updated_at ---------------------------- create or replace function public.touch_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end $$; drop trigger if exists trg_tasks_touch on public.tasks; create trigger trg_tasks_touch before update on public.tasks for each row execute function public.touch_updated_at(); drop trigger if exists trg_bugs_touch on public.bugs; create trigger trg_bugs_touch before update on public.bugs for each row execute function public.touch_updated_at(); drop trigger if exists trg_documents_touch on public.documents; create trigger trg_documents_touch before update on public.documents for each row execute function public.touch_updated_at(); drop trigger if exists trg_comments_touch on public.comments; create trigger trg_comments_touch before update on public.comments for each row execute function public.touch_updated_at(); -- ---------- Création auto du profil à l'inscription ------------------ create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ declare palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b']; r member_role; begin r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other'); insert into public.profiles (id, full_name, roles, avatar_color) values ( new.id, coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)), array[r], palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))] ) on conflict (id) do nothing; 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(); -- ---------- Réordonnancement du Kanban en une transaction ------------ -- L'appli envoie la liste ordonnée des tâches d'une colonne ; une seule -- requête réécrit statut + position pour toute la colonne. create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[]) returns void language plpgsql security invoker set search_path = public as $$ begin update public.tasks t set status = p_status, position = ordered.idx from ( select id, (ord - 1)::double precision as idx from unnest(p_ids) with ordinality as u(id, ord) ) ordered where t.id = ordered.id; end $$; grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated; -- ---------- Garde-fou : on ne se promeut pas admin soi-même ---------- create or replace function public.guard_is_admin() returns trigger language plpgsql security definer set search_path = public as $$ begin if new.is_admin is distinct from old.is_admin and current_user = 'authenticated' and not exists ( select 1 from public.profiles p where p.id = auth.uid() and p.is_admin ) then raise exception 'Seul un administrateur peut modifier le statut admin.'; end if; return new; end $$; drop trigger if exists trg_profiles_guard_admin on public.profiles; create trigger trg_profiles_guard_admin before update on public.profiles for each row execute function public.guard_is_admin(); -- ===================================================================== -- SÉCURITÉ (RLS) -- Équipe privée et de confiance : tout membre connecté lit et écrit le -- contenu des projets. Les exceptions sont explicites ci-dessous : -- · un profil ne se modifie que par son propriétaire -- · un projet ne se supprime que par son créateur ou un admin -- · un commentaire ne s'édite que par son auteur -- ===================================================================== alter table public.profiles enable row level security; alter table public.projects enable row level security; alter table public.tasks enable row level security; alter table public.bugs enable row level security; alter table public.time_logs enable row level security; alter table public.comments enable row level security; alter table public.attachments enable row level security; alter table public.documents enable row level security; alter table public.notifications enable row level security; -- ---------- Contenu partagé (tâches, bugs, temps) -------------------- do $$ declare t text; begin foreach t in array array['tasks','bugs','time_logs','documents'] loop execute format('drop policy if exists %I_all on public.%I', t, t); execute format( 'create policy %I_all on public.%I for all to authenticated using (true) with check (true)', t, t); end loop; end $$; -- ---------- Profils --------------------------------------------------- drop policy if exists profiles_all on public.profiles; drop policy if exists profiles_select on public.profiles; drop policy if exists profiles_update_self on public.profiles; create policy profiles_select on public.profiles for select to authenticated using (true); create policy profiles_update_self on public.profiles for update to authenticated using (id = auth.uid()) with check (id = auth.uid()); -- La création / suppression de comptes passe par l'Edge Function -- « admin-users » (clé service_role, côté serveur). -- ---------- Projets --------------------------------------------------- drop policy if exists projects_all on public.projects; drop policy if exists projects_select on public.projects; drop policy if exists projects_insert on public.projects; drop policy if exists projects_update on public.projects; drop policy if exists projects_delete on public.projects; create policy projects_select on public.projects for select to authenticated using (true); create policy projects_insert on public.projects for insert to authenticated with check (true); create policy projects_update on public.projects for update to authenticated using (true) with check (true); create policy projects_delete on public.projects for delete to authenticated using ( created_by = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin) ); -- ---------- Commentaires ---------------------------------------------- drop policy if exists comments_select on public.comments; drop policy if exists comments_insert on public.comments; drop policy if exists comments_update on public.comments; drop policy if exists comments_delete on public.comments; create policy comments_select on public.comments for select to authenticated using (true); create policy comments_insert on public.comments for insert to authenticated with check (author_id = auth.uid()); create policy comments_update on public.comments for update to authenticated using (author_id = auth.uid()) with check (author_id = auth.uid()); create policy comments_delete on public.comments for delete to authenticated using ( author_id = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin) ); -- ---------- Pièces jointes -------------------------------------------- drop policy if exists attachments_select on public.attachments; drop policy if exists attachments_insert on public.attachments; drop policy if exists attachments_delete on public.attachments; create policy attachments_select on public.attachments for select to authenticated using (true); create policy attachments_insert on public.attachments for insert to authenticated with check (uploader_id = auth.uid()); create policy attachments_delete on public.attachments for delete to authenticated using ( uploader_id = auth.uid() or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin) ); -- ---------- Notifications --------------------------------------------- drop policy if exists notifications_select on public.notifications; drop policy if exists notifications_insert on public.notifications; drop policy if exists notifications_update on public.notifications; drop policy if exists notifications_delete on public.notifications; create policy notifications_select on public.notifications for select to authenticated using (user_id = auth.uid()); -- Insertion ouverte : un membre doit pouvoir notifier ses coéquipiers. create policy notifications_insert on public.notifications for insert to authenticated with check (true); create policy notifications_update on public.notifications for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); create policy notifications_delete on public.notifications for delete to authenticated using (user_id = auth.uid()); -- ===================================================================== -- TEMPS RÉEL (synchro live entre membres) -- ===================================================================== do $$ declare t text; begin foreach t in array array['tasks','bugs','profiles','projects','time_logs', 'notifications','comments','attachments','documents'] loop begin execute format('alter publication supabase_realtime add table public.%I', t); exception when duplicate_object then null; end; end loop; end $$; -- ===================================================================== -- STOCKAGE -- · avatars : photos de profil -- · attachments : captures d'écran et fichiers joints aux bugs/tâches -- ===================================================================== insert into storage.buckets (id, name, public) values ('avatars', 'avatars', true), ('attachments', 'attachments', true) on conflict (id) do nothing; drop policy if exists avatars_read on storage.objects; drop policy if exists avatars_insert on storage.objects; drop policy if exists avatars_update on storage.objects; drop policy if exists avatars_delete on storage.objects; create policy avatars_read on storage.objects for select using (bucket_id = 'avatars'); create policy avatars_insert on storage.objects for insert to authenticated with check (bucket_id = 'avatars'); create policy avatars_update on storage.objects for update to authenticated using (bucket_id = 'avatars'); create policy avatars_delete on storage.objects for delete to authenticated using (bucket_id = 'avatars'); drop policy if exists attachments_read on storage.objects; drop policy if exists attachments_write on storage.objects; drop policy if exists attachments_remove on storage.objects; create policy attachments_read on storage.objects for select using (bucket_id = 'attachments'); create policy attachments_write on storage.objects for insert to authenticated with check (bucket_id = 'attachments'); create policy attachments_remove on storage.objects for delete to authenticated using (bucket_id = 'attachments'); -- ===================================================================== -- DERNIÈRE ÉTAPE — devenir administrateur (à faire UNE fois) -- Après avoir créé ton compte dans l'application, exécute : -- -- update public.profiles set is_admin = true -- where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com'); -- -- Tu pourras ensuite gérer tous les comptes depuis l'onglet Administration. -- ===================================================================== -- --------------------------------------------------------------------- -- Dernière ligne : forcer l'API à relire le schéma. -- Sans ça, PostgREST peut continuer à servir l'ancienne structure et -- renvoyer « Could not find the 'xxx' column ... in the schema cache » -- pendant plusieurs minutes après la migration. -- --------------------------------------------------------------------- notify pgrst, 'reload schema';