This commit is contained in:
2026-08-10 22:06:49 +02:00
parent 2a5d9bb0fd
commit 8bff7982ff
83 changed files with 17975 additions and 4824 deletions
+76 -57
View File
@@ -1,12 +1,14 @@
// =====================================================================
// Edge Function : admin-users
// Gère les comptes (lister / créer / supprimer / modifier rôle & admin).
// Gère les comptes (lister / créer / supprimer / modifier).
// Tourne sur les serveurs Supabase et garde la clé service_role en
// sécurité — elle n'est JAMAIS exposée dans l'appli.
// sécurité — elle n'est JAMAIS exposée dans l'application.
//
// Déploiement (voir le guide) :
// Déploiement :
// supabase functions deploy admin-users --no-verify-jwt
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous.)
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous)
//
// v10 : la colonne `profiles.role` a disparu ; seul `roles[]` fait foi.
// =====================================================================
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
@@ -20,6 +22,17 @@ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!
const ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!
const SERVICE_ROLE = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
const VALID_ROLES = [
'lead',
'programmer',
'artist',
'game_designer',
'sound',
'writer',
'qa',
'other'
]
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
@@ -27,8 +40,13 @@ function json(body: unknown, status = 200): Response {
})
}
/** Ne garde que des pôles valides (l'enum SQL rejetterait le reste). */
function cleanRoles(input: unknown): string[] {
if (!Array.isArray(input)) return []
return [...new Set(input.filter((r) => typeof r === 'string' && VALID_ROLES.includes(r)))]
}
Deno.serve(async (req) => {
// Pré-vol CORS (envoyé automatiquement par le navigateur/webview).
if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
try {
@@ -43,7 +61,7 @@ Deno.serve(async (req) => {
} = await caller.auth.getUser()
if (userErr || !user) return json({ error: 'Non authentifié.' }, 401)
// --- 2) Client "admin" (service_role) pour les opérations sensibles ---
// --- 2) Client « admin » (service_role) pour les opérations sensibles ---
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false }
})
@@ -61,63 +79,61 @@ Deno.serve(async (req) => {
switch (action) {
case 'list': {
// Emails dans auth.users + métadonnées dans profiles → on fusionne.
// Les emails sont dans auth.users, le reste dans profiles → fusion.
const { data: list, error } = await admin.auth.admin.listUsers({ perPage: 1000 })
if (error) throw error
const { data: profiles } = await admin.from('profiles').select('*')
const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr]))
const users = list.users.map((u) => {
const pr: any = byId.get(u.id) ?? {}
const roles =
Array.isArray(pr.roles) && pr.roles.length
? pr.roles
: pr.role
? [pr.role]
: []
return {
id: u.id,
email: u.email ?? '',
full_name: pr.full_name ?? '',
role: pr.role ?? 'other',
roles,
is_admin: pr.is_admin ?? false,
avatar_color: pr.avatar_color ?? '#7c5cff',
avatar_url: pr.avatar_url ?? null,
created_at: pr.created_at ?? u.created_at
}
})
const byId = new Map((profiles ?? []).map((pr: Record<string, unknown>) => [pr.id, pr]))
const users = list.users
.map((u) => {
const pr = (byId.get(u.id) ?? {}) as Record<string, unknown>
return {
id: u.id,
email: u.email ?? '',
full_name: (pr.full_name as string) ?? '',
roles: Array.isArray(pr.roles) ? pr.roles : [],
is_admin: Boolean(pr.is_admin),
avatar_color: (pr.avatar_color as string) ?? '#7c5cff',
avatar_url: (pr.avatar_url as string) ?? null,
created_at: (pr.created_at as string) ?? u.created_at
}
})
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'fr'))
return json(users)
}
case 'create': {
const { email, password, full_name, role, roles, is_admin } = p
const { email, password, full_name, roles, is_admin } = p
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
if (String(password).length < 6)
if (String(password).length < 6) {
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
}
// Liste de pôles (rôles) ; le premier fait office de rôle principal.
const roleList: string[] = Array.isArray(roles) && roles.length ? roles : role ? [role] : []
const roleList = cleanRoles(roles)
const primary = roleList[0] ?? 'other'
const name = full_name || String(email).split('@')[0]
// Crée le compte (email déjà confirmé : connexion immédiate possible).
// email_confirm : le membre peut se connecter immédiatement.
const { data: created, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name || email.split('@')[0], role: primary }
user_metadata: { full_name: name, role: primary }
})
if (error) return json({ error: error.message }, 400)
// Le trigger handle_new_user a créé le profil ; on cale les champs.
await admin
const { error: profErr } = await admin
.from('profiles')
.update({
full_name: full_name || email.split('@')[0],
role: primary,
full_name: name,
roles: roleList.length ? roleList : [primary],
is_admin: !!is_admin
is_admin: Boolean(is_admin)
})
.eq('id', created.user.id)
if (profErr) return json({ error: profErr.message }, 400)
return json({ ok: true, id: created.user.id })
}
@@ -125,58 +141,61 @@ Deno.serve(async (req) => {
case 'delete': {
const { id } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id)
if (id === user.id) {
return json({ error: 'Tu ne peux pas supprimer ton propre compte.' }, 400)
}
const { error } = await admin.auth.admin.deleteUser(id) // profil supprimé en cascade
if (error) return json({ error: error.message }, 400)
return json({ ok: true })
}
case 'update': {
const { id, full_name, email, password, role, roles, is_admin, avatar_url } = p
const { id, full_name, email, password, roles, is_admin, avatar_url } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id && is_admin === false)
if (id === user.id && is_admin === false) {
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
}
// --- a) Champs du compte auth (email / mot de passe / métadonnées) ---
// --- a) Compte auth (email / mot de passe / métadonnées) ---
const authPatch: Record<string, unknown> = {}
if (email !== undefined && String(email).trim()) {
authPatch.email = String(email).trim()
authPatch.email_confirm = true // évite le mail de confirmation
}
if (password !== undefined && String(password) !== '') {
if (String(password).length < 6)
if (String(password).length < 6) {
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
}
authPatch.password = String(password)
}
if (full_name !== undefined)
authPatch.user_metadata = { full_name, role: Array.isArray(roles) ? roles[0] : role }
if (full_name !== undefined) {
// On fusionne avec les métadonnées existantes plutôt que de les écraser.
const { data: existing } = await admin.auth.admin.getUserById(id)
authPatch.user_metadata = {
...(existing?.user?.user_metadata ?? {}),
full_name
}
}
if (Object.keys(authPatch).length > 0) {
const { error: authErr } = await admin.auth.admin.updateUserById(id, authPatch)
if (authErr) return json({ error: authErr.message }, 400)
}
// --- b) Champs du profil (nom, pôles, admin) ---
// --- b) Profil (nom, pôles, admin, avatar) ---
const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name
if (is_admin !== undefined) patch.is_admin = !!is_admin
if (is_admin !== undefined) patch.is_admin = Boolean(is_admin)
if (avatar_url !== undefined) patch.avatar_url = avatar_url || null
// Gestion des pôles : roles = liste complète, role = principal (roles[0]).
if (Array.isArray(roles)) {
const primary = roles[0] ?? 'other'
patch.roles = roles.length ? roles : [primary]
patch.role = primary
} else if (role !== undefined) {
patch.role = role
patch.roles = [role]
}
if (roles !== undefined) patch.roles = cleanRoles(roles)
if (Object.keys(patch).length > 0) {
const { error } = await admin.from('profiles').update(patch).eq('id', id)
if (error) return json({ error: error.message }, 400)
}
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0)
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0) {
return json({ error: 'Rien à modifier.' }, 400)
}
return json({ ok: true })
}
+345
View File
@@ -0,0 +1,345 @@
-- =====================================================================
-- GameDev Tracker — Migration v10 « Refonte »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
--
-- Ce que fait cette migration :
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
-- 1. Commentaires sur les tâches et les bugs
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
-- 3. Parité tâches/bugs : plusieurs pôles par bug
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
-- (profiles.role, tasks.pole, bugs.assignee_id)
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
-- 7. Temps réel sur les projets
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
--
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
-- pas à pas, la colonne manquait et l'application plantait en tentant
-- d'afficher les étiquettes d'une tâche.
--
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
-- tableau : jamais NULL, toujours un tableau vide par défaut.
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists tags text[] not null default '{}';
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
do $$
declare
r record;
begin
for r in
select * from (values
('tasks', 'tags', '''{}''::text[]'),
('tasks', 'poles', '''{}''::member_role[]'),
('tasks', 'assignee_ids', '''{}''::uuid[]'),
('tasks', 'checklist', '''[]''::jsonb'),
('bugs', 'poles', '''{}''::member_role[]'),
('bugs', 'assignee_ids', '''{}''::uuid[]'),
('profiles', 'roles', '''{}''::member_role[]')
) as t(tbl, col, empty)
loop
if exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
) then
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
end if;
end loop;
end $$;
-- ---------------------------------------------------------------------
-- 1) Parité bugs / tâches : plusieurs pôles par bug
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists poles member_role[] not null default '{}';
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
-- ---------------------------------------------------------------------
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()
);
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);
alter table public.comments enable row level security;
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;
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
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)
);
-- ---------------------------------------------------------------------
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
-- ---------------------------------------------------------------------
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, -- chemin dans le bucket "attachments"
size bigint not null default 0,
mime text,
created_at timestamptz not null default now()
);
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);
alter table public.attachments enable row level security;
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)
);
-- Bucket de stockage des fichiers joints.
insert into storage.buckets (id, name, public)
values ('attachments', 'attachments', true)
on conflict (id) do nothing;
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');
-- ---------------------------------------------------------------------
-- 4) Notifications : sur QUOI pointe la notification
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
-- ---------------------------------------------------------------------
alter table public.notifications
add column if not exists entity_kind text not null default 'task';
update public.notifications
set entity_kind = kind
where kind in ('task', 'bug') and entity_kind is distinct from kind;
create index if not exists idx_notifications_user_unread
on public.notifications(user_id, read, created_at desc);
-- ---------------------------------------------------------------------
-- 5) Réordonnancement du Kanban en UNE requête
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
-- allers-retours non transactionnels — un échec corrompait l'ordre).
-- ---------------------------------------------------------------------
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
returns void
language plpgsql
security invoker -- la RLS de l'appelant s'applique normalement
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;
-- ---------------------------------------------------------------------
-- 6) Suppression d'un projet : créateur ou administrateur seulement
-- (avant : n'importe quel membre pouvait effacer un projet entier
-- et tout son contenu en cascade)
-- ---------------------------------------------------------------------
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)
);
-- ---------------------------------------------------------------------
-- 7) Temps réel : projets, commentaires, pièces jointes
-- ---------------------------------------------------------------------
do $$ begin
alter publication supabase_realtime add table public.projects;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.comments;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.attachments;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 8) Création de profil à l'inscription : couleur d'avatar variée
-- (avant, tout le monde héritait du même violet #7c5cff)
-- ---------------------------------------------------------------------
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 $$;
-- ---------------------------------------------------------------------
-- 9) Suppression des colonnes « de compatibilité »
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
-- utilise plus).
-- ---------------------------------------------------------------------
-- a) profiles.role → profiles.roles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
alter table public.profiles drop column role;
end if;
end $$;
-- b) tasks.pole → tasks.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
alter table public.tasks drop column pole;
end if;
end $$;
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
update public.bugs set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
alter table public.bugs drop column assignee_id;
end if;
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
alter table public.bugs drop column pole;
end if;
end $$;
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
-- perdre — mais plus personne ne la remplit.
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
alter table public.bugs alter column priority set default 'medium';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 10) Index manquants sur les colonnes les plus filtrées
-- ---------------------------------------------------------------------
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
-- ---------------------------------------------------------------------
-- 11) Temps passé sur un BUG (parité avec les tâches)
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
-- rien (activité libre : réunion, veille…). Jamais les deux.
-- ---------------------------------------------------------------------
alter table public.time_logs
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
do $$ begin
alter table public.time_logs
add constraint time_logs_one_target
check (not (task_id is not null and bug_id is not null));
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 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';
+46
View File
@@ -0,0 +1,46 @@
-- =====================================================================
-- GameDev Tracker — Migration v11 « Documentation »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée.
--
-- Ajoute les fiches de documentation : GDD, conventions de code, notes
-- de réunion, procédures… Créées dans l'app ou importées depuis un
-- fichier Markdown / Word.
-- =====================================================================
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,
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
-- cherchables et modifiables dans l'application.
content text not null default '',
tags text[] not null default '{}',
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
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()
);
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
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();
alter table public.documents enable row level security;
drop policy if exists documents_all on public.documents;
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
create policy documents_all on public.documents
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.documents;
exception when duplicate_object then null; end $$;
notify pgrst, 'reload schema';
-70
View File
@@ -1,70 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v2
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (pour une base déjà créée avec schema.sql v1)
--
-- Contenu :
-- • Suppression des JALONS (milestones)
-- • Notion de PÔLE sur les tâches et les bugs
-- • Plusieurs personnes assignables sur une tâche (assignee_ids)
-- • Système de NOTIFICATIONS (assignation à un pôle)
-- =====================================================================
-- ---------- Pôle sur tâches & bugs (réutilise l'enum member_role) -----
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
-- ---------- Plusieurs assignés par tâche -----------------------------
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le nouveau tableau.
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null
and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column if exists assignee_id;
-- ---------- Suppression des jalons -----------------------------------
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
-- ---------- 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'
title text not null, -- ex : « Nouvelle tâche »
body text, -- titre de la tâche / du bug
entity_id uuid, -- id de la tâche / du bug
pole member_role, -- pôle destinataire
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
-- membre connecté (afin de notifier les autres membres d'un pôle).
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());
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());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-25
View File
@@ -1,25 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v3
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (après migration_v2.sql)
--
-- Contenu :
-- • Temps « libre » dans la feuille de temps (réunion, etc.) :
-- une entrée peut ne pas être liée à une tâche du Kanban.
-- =====================================================================
-- La tâche devient optionnelle ; un libellé libre la remplace si absente.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
-- Rattachement direct au projet (pour retrouver aussi les entrées sans tâche).
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
-- Backfill : les entrées existantes héritent du projet de leur tâche.
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_project on public.time_logs(project_id);
-18
View File
@@ -1,18 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v4
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (après migration_v3.sql)
--
-- Contenu :
-- • Un membre peut appartenir à PLUSIEURS pôles (rôles).
-- profiles.role reste le rôle « principal » ; profiles.roles
-- contient l'ensemble des pôles.
-- =====================================================================
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
-- Backfill : les profils existants héritent de leur rôle unique actuel.
update public.profiles
set roles = array[role]
where roles = '{}';
+29
View File
@@ -0,0 +1,29 @@
-- =====================================================================
-- Bucket « releases » — hébergement des mises à jour de l'application
-- À exécuter UNE SEULE FOIS : Supabase > SQL Editor > New query > Run
--
-- Ce bucket contient l'installeur signé et le fichier « latest.json »
-- que chaque application interroge au lancement.
--
-- Lecture PUBLIQUE : les collègues doivent pouvoir télécharger la mise
-- à jour avant même d'être connectés. Aucun risque : le paquet est
-- signé, et une mise à jour non signée par la clé privée du projet est
-- purement et simplement refusée par Tauri.
--
-- Écriture : réservée au script « npm run release », qui s'authentifie
-- avec la clé service_role depuis la machine de l'administrateur.
-- =====================================================================
insert into storage.buckets (id, name, public)
values ('releases', 'releases', true)
on conflict (id) do nothing;
drop policy if exists releases_read on storage.objects;
create policy releases_read on storage.objects
for select using (bucket_id = 'releases');
-- Personne d'autre que service_role n'écrit ici : aucune policy d'insert
-- n'est créée volontairement (service_role contourne la RLS).
notify pgrst, 'reload schema';
+463 -302
View File
@@ -1,302 +1,463 @@
-- =====================================================================
-- GameDev Tracker — Schéma de base de données (Supabase / PostgreSQL)
-- À exécuter UNE SEULE FOIS dans : Supabase > SQL Editor > New query
-- =====================================================================
-- ---------- 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 $$;
-- ---------- Profils (étend auth.users) -------------------------------
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',
role member_role not null default 'other', -- rôle principal (= roles[0])
roles member_role[] not null default '{}', -- tous les pôles du membre
avatar_color text not null default '#7c5cff',
avatar_url text, -- photo de profil (optionnelle)
onboarded boolean not null default false, -- a terminé l'accueil
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 ---------------------------------------------------
-- Un « pôle » (= member_role) reçoit la tâche ; une ou plusieurs
-- personnes de ce pôle peuvent y être assignées (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',
pole member_role, -- pôle principal (= poles[0], compat)
poles member_role[] not null default '{}', -- tous les pôles concernés par la tâche
assignee_ids uuid[] 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 -----------------------------------------------------
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',
pole member_role,
assignee_id uuid references public.profiles(id) on delete set null,
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()
);
create index if not exists idx_tasks_project on public.tasks(project_id);
create index if not exists idx_bugs_project on public.bugs(project_id);
-- ---------- 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();
-- ---------- 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 r member_role;
begin
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
insert into public.profiles (id, full_name, role, roles)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
r,
array[r]
)
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();
-- ---------- Sécurité (RLS) -------------------------------------------
-- Équipe privée et de confiance : tout membre connecté a accès complet.
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;
do $$
declare t text;
begin
foreach t in array array['profiles','projects','tasks','bugs'] 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 $$;
-- ---------- Realtime (synchro live entre membres) --------------------
do $$ begin
alter publication supabase_realtime add table public.tasks;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.bugs;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.profiles;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- ADMINISTRATION : comptes & rôles gérés par un panel admin in-app
-- =====================================================================
-- ---------- Champ "administrateur" sur les profils -------------------
alter table public.profiles
add column if not exists is_admin boolean not null default false;
-- ---------- Sécurité fine sur les profils ----------------------------
-- On remplace la policy permissive "profiles_all" par des règles précises :
-- * tout le monde (connecté) PEUT LIRE les profils (affichage de l'équipe)
-- * chacun ne peut MODIFIER que SON propre profil
-- * la création/suppression de comptes passe uniquement par l'Edge
-- Function "admin-users" (clé service_role, côté serveur).
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());
-- ---------- Garde-fou : on ne se promeut pas admin soi-même ----------
-- Empêche un membre connecté (rôle "authenticated") de changer le champ
-- is_admin. Les opérations légitimes passent par l'Edge Function
-- (rôle "service_role") ou par le SQL Editor (rôle "postgres"), tous deux
-- autorisés ici. Un admin déjà en place peut aussi modifier le statut.
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();
-- ---------- Amorçage du PREMIER admin (à faire UNE fois) -------------
-- Après ta première inscription dans l'appli, exécute cette ligne en
-- remplaçant l'email par le tien pour devenir administrateur :
--
-- update public.profiles set is_admin = true
-- where id = (select id from auth.users where email = 'TON_EMAIL@ exemple.com');
--
-- Ensuite, tu pourras créer/gérer tous les autres comptes depuis l'appli.
-- =====================================================================
-- TAGS (« jobs ») & SUIVI DU TEMPS
-- =====================================================================
-- ---------- Tags sur les tâches (ex: Programmation, Art, Audio) ------
alter table public.tasks
add column if not exists tags text[] not null default '{}';
-- ---------- Journal du temps passé (par tâche OU activité libre) -----
-- task_id null + label => temps « libre » (réunion, etc.).
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,
label text,
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()
);
create index if not exists idx_time_logs_task on public.time_logs(task_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);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- NOTIFICATIONS (assignation d'une tâche / d'un bug à un pôle)
-- =====================================================================
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'
title text not null, -- ex : « Nouvelle tâche »
body text, -- titre de la tâche / du bug
entity_id uuid, -- id de la tâche / du bug
pole member_role, -- pôle destinataire
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
-- membre connecté (afin de notifier les autres membres d'un pôle).
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());
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());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- STOCKAGE : photos de profil (bucket "avatars")
-- =====================================================================
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', 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');
-- =====================================================================
-- 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';
+563 -156
View File
@@ -1,156 +1,563 @@
-- =====================================================================
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
-- =====================================================================
-- ---------------------------------------------------------------------
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column assignee_id;
end if;
end $$;
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
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,
title text not null,
body text,
entity_id uuid,
pole member_role,
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
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());
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());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
-- ---------------------------------------------------------------------
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
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,
label text,
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()
);
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_task on public.time_logs(task_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);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v4 — Plusieurs pôles (rôles) par membre
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
update public.profiles
set roles = array[role]
where roles = '{}';
-- ---------------------------------------------------------------------
-- v5 — Accueil (onboarding) des nouveaux membres
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists onboarded boolean not null default false;
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
update public.profiles set onboarded = true where onboarded = false;
-- ---------------------------------------------------------------------
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
-- ---------------------------------------------------------------------
alter table public.profiles add column if not exists avatar_url text;
-- Bucket public "avatars" pour héberger les photos de profil.
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Lecture publique ; écriture réservée aux membres connectés.
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');
-- ---------------------------------------------------------------------
-- v7 — Plusieurs pôles par tâche
-- ---------------------------------------------------------------------
alter table public.tasks
add column if not exists poles member_role[] not null default '{}';
-- Reprend le pôle unique existant dans le tableau (si présent).
update public.tasks
set poles = array[pole]
where pole is not null and poles = '{}';
-- =====================================================================
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
-- =====================================================================
-- ---------------------------------------------------------------------
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column assignee_id;
end if;
end $$;
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
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,
title text not null,
body text,
entity_id uuid,
pole member_role,
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
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());
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());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
-- ---------------------------------------------------------------------
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
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,
label text,
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()
);
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_task on public.time_logs(task_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);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v4 — Plusieurs pôles (rôles) par membre
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
update public.profiles
set roles = array[role]
where roles = '{}';
-- ---------------------------------------------------------------------
-- v5 — Accueil (onboarding) des nouveaux membres
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists onboarded boolean not null default false;
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
update public.profiles set onboarded = true where onboarded = false;
-- ---------------------------------------------------------------------
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
-- ---------------------------------------------------------------------
alter table public.profiles add column if not exists avatar_url text;
-- Bucket public "avatars" pour héberger les photos de profil.
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Lecture publique ; écriture réservée aux membres connectés.
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');
-- ---------------------------------------------------------------------
-- v7 — Plusieurs pôles par tâche
-- ---------------------------------------------------------------------
alter table public.tasks
add column if not exists poles member_role[] not null default '{}';
-- Reprend le pôle unique existant dans le tableau (si présent).
update public.tasks
set poles = array[pole]
where pole is not null and poles = '{}';
-- ---------------------------------------------------------------------
-- v8 — Checklists (cases à cocher) sur les tâches
-- ---------------------------------------------------------------------
-- Liste d'éléments [{ "id": "...", "text": "...", "done": false }, ...]
alter table public.tasks
add column if not exists checklist jsonb not null default '[]'::jsonb;
-- ---------------------------------------------------------------------
-- v9 — Assignation MULTIPLE des bugs (parité avec les tâches)
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si présent).
update public.bugs
set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
-- =====================================================================
-- GameDev Tracker — Migration v10 « Refonte »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
--
-- Ce que fait cette migration :
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
-- 1. Commentaires sur les tâches et les bugs
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
-- 3. Parité tâches/bugs : plusieurs pôles par bug
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
-- (profiles.role, tasks.pole, bugs.assignee_id)
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
-- 7. Temps réel sur les projets
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
--
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
-- pas à pas, la colonne manquait et l'application plantait en tentant
-- d'afficher les étiquettes d'une tâche.
--
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
-- tableau : jamais NULL, toujours un tableau vide par défaut.
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists tags text[] not null default '{}';
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
do $$
declare
r record;
begin
for r in
select * from (values
('tasks', 'tags', '''{}''::text[]'),
('tasks', 'poles', '''{}''::member_role[]'),
('tasks', 'assignee_ids', '''{}''::uuid[]'),
('tasks', 'checklist', '''[]''::jsonb'),
('bugs', 'poles', '''{}''::member_role[]'),
('bugs', 'assignee_ids', '''{}''::uuid[]'),
('profiles', 'roles', '''{}''::member_role[]')
) as t(tbl, col, empty)
loop
if exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
) then
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
end if;
end loop;
end $$;
-- ---------------------------------------------------------------------
-- 1) Parité bugs / tâches : plusieurs pôles par bug
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists poles member_role[] not null default '{}';
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
-- ---------------------------------------------------------------------
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()
);
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);
alter table public.comments enable row level security;
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;
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
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)
);
-- ---------------------------------------------------------------------
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
-- ---------------------------------------------------------------------
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, -- chemin dans le bucket "attachments"
size bigint not null default 0,
mime text,
created_at timestamptz not null default now()
);
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);
alter table public.attachments enable row level security;
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)
);
-- Bucket de stockage des fichiers joints.
insert into storage.buckets (id, name, public)
values ('attachments', 'attachments', true)
on conflict (id) do nothing;
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');
-- ---------------------------------------------------------------------
-- 4) Notifications : sur QUOI pointe la notification
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
-- ---------------------------------------------------------------------
alter table public.notifications
add column if not exists entity_kind text not null default 'task';
update public.notifications
set entity_kind = kind
where kind in ('task', 'bug') and entity_kind is distinct from kind;
create index if not exists idx_notifications_user_unread
on public.notifications(user_id, read, created_at desc);
-- ---------------------------------------------------------------------
-- 5) Réordonnancement du Kanban en UNE requête
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
-- allers-retours non transactionnels — un échec corrompait l'ordre).
-- ---------------------------------------------------------------------
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
returns void
language plpgsql
security invoker -- la RLS de l'appelant s'applique normalement
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;
-- ---------------------------------------------------------------------
-- 6) Suppression d'un projet : créateur ou administrateur seulement
-- (avant : n'importe quel membre pouvait effacer un projet entier
-- et tout son contenu en cascade)
-- ---------------------------------------------------------------------
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)
);
-- ---------------------------------------------------------------------
-- 7) Temps réel : projets, commentaires, pièces jointes
-- ---------------------------------------------------------------------
do $$ begin
alter publication supabase_realtime add table public.projects;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.comments;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.attachments;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 8) Création de profil à l'inscription : couleur d'avatar variée
-- (avant, tout le monde héritait du même violet #7c5cff)
-- ---------------------------------------------------------------------
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 $$;
-- ---------------------------------------------------------------------
-- 9) Suppression des colonnes « de compatibilité »
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
-- utilise plus).
-- ---------------------------------------------------------------------
-- a) profiles.role → profiles.roles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
alter table public.profiles drop column role;
end if;
end $$;
-- b) tasks.pole → tasks.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
alter table public.tasks drop column pole;
end if;
end $$;
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
update public.bugs set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
alter table public.bugs drop column assignee_id;
end if;
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
alter table public.bugs drop column pole;
end if;
end $$;
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
-- perdre — mais plus personne ne la remplit.
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
alter table public.bugs alter column priority set default 'medium';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 10) Index manquants sur les colonnes les plus filtrées
-- ---------------------------------------------------------------------
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
-- ---------------------------------------------------------------------
-- 11) Temps passé sur un BUG (parité avec les tâches)
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
-- rien (activité libre : réunion, veille…). Jamais les deux.
-- ---------------------------------------------------------------------
alter table public.time_logs
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
do $$ begin
alter table public.time_logs
add constraint time_logs_one_target
check (not (task_id is not null and bug_id is not null));
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 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';
-- =====================================================================
-- v11 — Documentation (fiches créées ou importées)
-- =====================================================================
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,
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
-- cherchables et modifiables dans l'application.
content text not null default '',
tags text[] not null default '{}',
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
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()
);
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
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();
alter table public.documents enable row level security;
drop policy if exists documents_all on public.documents;
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
create policy documents_all on public.documents
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.documents;
exception when duplicate_object then null; end $$;
notify pgrst, 'reload schema';