diff --git a/VIEWER_ROLE_MIGRATION.sql b/VIEWER_ROLE_MIGRATION.sql index 9d000e2..10d391e 100644 --- a/VIEWER_ROLE_MIGRATION.sql +++ b/VIEWER_ROLE_MIGRATION.sql @@ -1,7 +1,64 @@ --- Run this in Supabase SQL Editor to allow a read-only viewer role. +-- Run in Supabase SQL Editor. +-- 1) Allow viewer role alter table public.collection_members drop constraint if exists collection_members_role_check; alter table public.collection_members add constraint collection_members_role_check check (role in ('owner', 'member', 'viewer')); + +-- 2) Ensure owners can delete members from their collections +-- (and users can still remove themselves for leave flow). +drop policy if exists "Owner can remove members" on public.collection_members; + +create policy "Owner can remove members" +on public.collection_members +for delete +to authenticated +using ( + collection_id in ( + select c.id + from public.collections c + where c.owner_id = auth.uid() + ) + or user_id = auth.uid() +); + +-- 3) Stable, policy-safe removal RPC for app use +create or replace function public.remove_collection_member( + p_collection_id uuid, + p_member_user_id uuid +) +returns void +language plpgsql +security definer +set search_path = public +as $$ +declare + v_owner_id uuid; +begin + select c.owner_id + into v_owner_id + from public.collections c + where c.id = p_collection_id; + + if v_owner_id is null then + raise exception 'Collection not found.'; + end if; + + if v_owner_id <> auth.uid() then + raise exception 'Only the collection owner can remove members.'; + end if; + + if p_member_user_id = v_owner_id then + raise exception 'Collection owner cannot be removed.'; + end if; + + delete from public.collection_members cm + where cm.collection_id = p_collection_id + and cm.user_id = p_member_user_id + and cm.role <> 'owner'; +end; +$$; + +grant execute on function public.remove_collection_member(uuid, uuid) to authenticated; diff --git a/lib/services/collection_service.dart b/lib/services/collection_service.dart index 264313e..a169eaf 100644 --- a/lib/services/collection_service.dart +++ b/lib/services/collection_service.dart @@ -386,17 +386,43 @@ class CollectionService { throw Exception('Collection owner cannot be removed.'); } - await supabase - .from('collection_members') - .delete() - .eq('collection_id', collectionId) - .eq('user_id', memberUserId); + var deleted = false; + try { + await supabase.rpc('remove_collection_member', params: { + 'p_collection_id': collectionId, + 'p_member_user_id': memberUserId, + }); + deleted = true; + } catch (e) { + final message = e.toString(); + final rpcUnavailable = message.contains('remove_collection_member') && + (message.contains('not found') || + message.contains('does not exist') || + message.contains('PGRST202')); + + if (!rpcUnavailable) { + rethrow; + } + + await supabase + .from('collection_members') + .delete() + .eq('collection_id', collectionId) + .eq('user_id', memberUserId); + deleted = true; + } + + if (!deleted) { + throw Exception('Member removal failed.'); + } final membersAfter = await getMembers(collectionId); final stillExists = membersAfter.any((member) => member.userId == memberUserId); if (stillExists) { - throw Exception('Member removal did not persist. Please check RLS delete policy.'); + throw Exception( + 'Member removal did not persist. Run VIEWER_ROLE_MIGRATION.sql to update policies/functions.', + ); } }