-- ============================================================ -- 018_account_member_rpcs.sql — RPCs for member management -- -- Why RPCs and not direct UPDATEs from the client -- -- The `profiles_update` RLS policy from migration 017 only -- allows a user to update their *own* profile row. That is -- correct for self-service edits (name, avatar) but it would -- block an admin from changing a teammate's role or moving -- a removed member to a fresh personal account. -- -- These three SECURITY DEFINER functions are the supervised -- escape hatches: they bypass RLS to do exactly the writes the -- matching API route needs, but every function self-checks the -- caller's authority via `auth.uid()` first, so the privilege -- bypass is scoped tightly. -- -- Error contract -- -- All functions raise Postgres exceptions with these SQLSTATEs: -- 42501 ("insufficient_privilege") — forbidden -- 22023 ("invalid_parameter_value") — bad input / 400 -- The `toErrorResponse` helper on the API side maps each to -- the right HTTP status, with the RAISE message surfaced to -- the caller. -- -- Idempotent — safe to run multiple times. -- ============================================================ -- ============================================================ -- set_member_role(p_user_id, p_new_role) -- -- Admin+ changes another member's role within the caller's -- account. Cannot promote to / demote from 'owner' (that is the -- transfer endpoint). Cannot target self. -- ============================================================ CREATE OR REPLACE FUNCTION public.set_member_role( p_user_id UUID, p_new_role account_role_enum ) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_caller_account_id UUID; v_caller_role account_role_enum; v_target_account_id UUID; v_target_role account_role_enum; BEGIN -- Caller must be authenticated. IF auth.uid() IS NULL THEN RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501'; END IF; -- Resolve caller's account + role. SELECT account_id, account_role INTO v_caller_account_id, v_caller_role FROM profiles WHERE user_id = auth.uid(); IF v_caller_account_id IS NULL THEN RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501'; END IF; -- Caller must be admin+. IF v_caller_role NOT IN ('owner', 'admin') THEN RAISE EXCEPTION 'This action requires the admin role or higher' USING ERRCODE = '42501'; END IF; -- Can't change own role via this endpoint. IF p_user_id = auth.uid() THEN RAISE EXCEPTION 'Cannot change your own role' USING ERRCODE = '22023'; END IF; -- Resolve target. SELECT account_id, account_role INTO v_target_account_id, v_target_role FROM profiles WHERE user_id = p_user_id; IF v_target_account_id IS NULL THEN RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023'; END IF; -- Target must be in caller's account. IF v_target_account_id <> v_caller_account_id THEN RAISE EXCEPTION 'Target user is not a member of your account' USING ERRCODE = '42501'; END IF; -- Owner role changes go through transfer_account_ownership. IF v_target_role = 'owner' THEN RAISE EXCEPTION 'Use transfer_account_ownership to demote an owner' USING ERRCODE = '22023'; END IF; IF p_new_role = 'owner' THEN RAISE EXCEPTION 'Use transfer_account_ownership to promote to owner' USING ERRCODE = '22023'; END IF; UPDATE profiles SET account_role = p_new_role WHERE user_id = p_user_id; END; $$; ALTER FUNCTION public.set_member_role(UUID, account_role_enum) OWNER TO postgres; REVOKE ALL ON FUNCTION public.set_member_role(UUID, account_role_enum) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.set_member_role(UUID, account_role_enum) TO authenticated; -- ============================================================ -- remove_account_member(p_user_id) -- -- Admin+ removes another member from the caller's account. The -- removed user is NOT deleted from auth.users — they keep their -- login. Instead, a fresh personal account is created on the fly -- and their profile is reassigned to it as 'owner'. This is the -- mirror image of the signup trigger: the user effectively -- "starts over" with an empty account, free to invite their own -- teammates if they want. -- -- Cannot target the owner. Cannot target self. -- ============================================================ CREATE OR REPLACE FUNCTION public.remove_account_member( p_user_id UUID ) RETURNS UUID -- the new personal account id LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_caller_account_id UUID; v_caller_role account_role_enum; v_target_account_id UUID; v_target_role account_role_enum; v_target_name TEXT; v_target_email TEXT; v_new_account_id UUID; BEGIN IF auth.uid() IS NULL THEN RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501'; END IF; SELECT account_id, account_role INTO v_caller_account_id, v_caller_role FROM profiles WHERE user_id = auth.uid(); IF v_caller_account_id IS NULL THEN RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501'; END IF; IF v_caller_role NOT IN ('owner', 'admin') THEN RAISE EXCEPTION 'This action requires the admin role or higher' USING ERRCODE = '42501'; END IF; IF p_user_id = auth.uid() THEN RAISE EXCEPTION 'Cannot remove yourself; transfer ownership or leave the account instead' USING ERRCODE = '22023'; END IF; SELECT account_id, account_role, full_name, email INTO v_target_account_id, v_target_role, v_target_name, v_target_email FROM profiles WHERE user_id = p_user_id; IF v_target_account_id IS NULL THEN RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023'; END IF; IF v_target_account_id <> v_caller_account_id THEN RAISE EXCEPTION 'Target user is not a member of your account' USING ERRCODE = '42501'; END IF; IF v_target_role = 'owner' THEN RAISE EXCEPTION 'Cannot remove the account owner; transfer ownership first' USING ERRCODE = '22023'; END IF; -- Spin up a fresh personal account for the removed user. Mirror -- of handle_new_user's logic — keep them whole, just relocated. INSERT INTO accounts (name, owner_user_id) VALUES ( COALESCE(NULLIF(v_target_name, ''), v_target_email, 'My account'), p_user_id ) RETURNING id INTO v_new_account_id; UPDATE profiles SET account_id = v_new_account_id, account_role = 'owner' WHERE user_id = p_user_id; RETURN v_new_account_id; END; $$; ALTER FUNCTION public.remove_account_member(UUID) OWNER TO postgres; REVOKE ALL ON FUNCTION public.remove_account_member(UUID) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.remove_account_member(UUID) TO authenticated; -- ============================================================ -- transfer_account_ownership(p_new_owner_user_id) -- -- Owner only. Atomically: -- - demotes the current owner to 'admin' -- - promotes the target to 'owner' -- - updates accounts.owner_user_id -- -- Both writes happen in the same statement-level transaction. -- ============================================================ CREATE OR REPLACE FUNCTION public.transfer_account_ownership( p_new_owner_user_id UUID ) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_caller_account_id UUID; v_caller_role account_role_enum; v_target_account_id UUID; v_target_role account_role_enum; BEGIN IF auth.uid() IS NULL THEN RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501'; END IF; SELECT account_id, account_role INTO v_caller_account_id, v_caller_role FROM profiles WHERE user_id = auth.uid(); IF v_caller_account_id IS NULL THEN RAISE EXCEPTION 'Caller has no account' USING ERRCODE = '42501'; END IF; IF v_caller_role <> 'owner' THEN RAISE EXCEPTION 'Only the account owner can transfer ownership' USING ERRCODE = '42501'; END IF; IF p_new_owner_user_id = auth.uid() THEN RAISE EXCEPTION 'You are already the owner' USING ERRCODE = '22023'; END IF; SELECT account_id, account_role INTO v_target_account_id, v_target_role FROM profiles WHERE user_id = p_new_owner_user_id; IF v_target_account_id IS NULL THEN RAISE EXCEPTION 'Target user not found' USING ERRCODE = '22023'; END IF; IF v_target_account_id <> v_caller_account_id THEN RAISE EXCEPTION 'Target user is not a member of your account' USING ERRCODE = '42501'; END IF; -- Demote current owner first so the temporary state where the -- account has zero owners is never visible — both writes happen -- in the same function transaction. UPDATE profiles SET account_role = 'admin' WHERE user_id = auth.uid(); UPDATE profiles SET account_role = 'owner' WHERE user_id = p_new_owner_user_id; UPDATE accounts SET owner_user_id = p_new_owner_user_id WHERE id = v_caller_account_id; END; $$; ALTER FUNCTION public.transfer_account_ownership(UUID) OWNER TO postgres; REVOKE ALL ON FUNCTION public.transfer_account_ownership(UUID) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.transfer_account_ownership(UUID) TO authenticated;