wacrm/supabase/migrations/018_account_member_rpcs.sql
Arnas Donauskas 0a1cf97d6b
feat(api): account + members management endpoints
PR 3 of the multi-user accounts series. Lands the server-side
endpoints the upcoming Members tab UI will call. Built on the
helpers from #169 (`requireRole`, `getCurrentAccount`,
`toErrorResponse`).

API surface
- GET    /api/account                     — caller's account + role
- PATCH  /api/account                     — rename                  (admin+)
- GET    /api/account/members             — roster; email visible to admin+ only
- PATCH  /api/account/members/[userId]    — change role             (admin+)
- DELETE /api/account/members/[userId]    — remove                  (admin+)
- POST   /api/account/transfer-ownership  — atomic owner handoff    (owner)

Member-role writes can't ride on the regular `profiles_update` RLS
policy (which only allows self-edits — admins can't modify a
teammate's row that way). The three SECURITY DEFINER RPCs in the
new migration are the scoped escape hatches:

- `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks
  promotion to / demotion from owner (use transfer instead) and
  self-targeting.
- `remove_account_member(p_user_id)` — admin+ only. Creates a
  fresh personal account for the removed user and reassigns
  their profile as its 'owner' — mirror of the signup flow, so
  they keep their login and just "start over". Blocks removing
  the owner or self.
- `transfer_account_ownership(p_new_owner_user_id)` — owner only.
  Atomically demotes current owner → admin, promotes target →
  owner, and points accounts.owner_user_id at the new owner.
  Demote-first ordering means the temporary "zero owners" state
  is never visible.

Error contract
- Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad
  input) and a human-readable RAISE message. The route handlers
  map these onto 403 / 400 respectively; anything else collapses
  to a generic 500.
- The TS guards (e.g. `requireRole('owner')` on the transfer
  route, `isAccountRole(role)` on the PATCH route) are
  belt-and-braces — they fail fast before the Supabase round
  trip but the RPCs would catch the same violations regardless.

Field visibility
- `GET /api/account/members` returns `email` only when the
  caller is admin+. Agents and viewers see name + avatar + role
  + joined date — matches the planning decision so a viewer
  can't harvest the team's email list.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ
                        (dynamic — they all use `next/headers`
                        cookies via the SSR client)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:00:27 +03:00

284 lines
9.0 KiB
PL/PgSQL

-- ============================================================
-- 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;