From 0a1cf97d6be5d3c514859fbc6a6d281772addd66 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 15:00:27 +0300 Subject: [PATCH] feat(api): account + members management endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 26 ++ src/app/api/account/members/[userId]/route.ts | 103 +++++++ src/app/api/account/members/route.ts | 73 +++++ src/app/api/account/route.ts | 88 ++++++ .../api/account/transfer-ownership/route.ts | 79 +++++ .../migrations/018_account_member_rpcs.sql | 283 ++++++++++++++++++ 6 files changed, 652 insertions(+) create mode 100644 src/app/api/account/members/[userId]/route.ts create mode 100644 src/app/api/account/members/route.ts create mode 100644 src/app/api/account/route.ts create mode 100644 src/app/api/account/transfer-ownership/route.ts create mode 100644 supabase/migrations/018_account_member_rpcs.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbd074..226e7e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,25 @@ as the sole owner of their own account and sees identical data. - **The signup trigger (`handle_new_user`) now also creates a personal account** and links the new profile to it as `owner`. +### Added + +- **Account & member management API** — server-side endpoints + for the upcoming Members tab UI. All routes are role-gated and + return Supabase-RLS-scoped data. + - `GET /api/account` — caller's account + role. Any member. + - `PATCH /api/account` — rename the account. Admin+. + - `GET /api/account/members` — list members. Email visible to + admin+ only; agents/viewers see name + avatar + role + + joined date. + - `PATCH /api/account/members/[userId]` — change a member's + role. Admin+. Owner promotion/demotion goes through the + transfer endpoint instead. + - `DELETE /api/account/members/[userId]` — remove a member. + Admin+. The removed user keeps their login and is moved to a + freshly-created personal account (mirror of the signup flow). + - `POST /api/account/transfer-ownership` — owner only. Atomic + swap with the named member. + ### Migration required Apply against your Supabase project before deploying this version: @@ -50,6 +69,13 @@ Apply against your Supabase project before deploying this version: every existing user is mapped to a freshly-created account with role `owner` and every existing row of theirs is linked to that account. +- `supabase/migrations/018_account_member_rpcs.sql` — adds three + `SECURITY DEFINER` RPCs (`set_member_role`, + `remove_account_member`, `transfer_account_ownership`) that + back the member-management API. They self-check the caller's + role and raise SQLSTATE `42501` / `22023` on forbidden / bad + input so the API layer can map cleanly to 403 / 400. + Idempotent. ## [0.2.2] — 2026-05-29 diff --git a/src/app/api/account/members/[userId]/route.ts b/src/app/api/account/members/[userId]/route.ts new file mode 100644 index 0000000..2ecc912 --- /dev/null +++ b/src/app/api/account/members/[userId]/route.ts @@ -0,0 +1,103 @@ +// ============================================================ +// /api/account/members/[userId] +// +// PATCH — change a member's role. Admin+. +// DELETE — remove a member. Admin+. +// +// Both delegate to SECURITY DEFINER RPCs from migration 018: +// - set_member_role(p_user_id, p_new_role) +// - remove_account_member(p_user_id) +// +// The RPCs do the *real* authorisation work — caller must be +// admin+, target must be in caller's account, target can't be the +// owner, can't be self. The TS layer here only forwards the call +// and maps Postgres SQLSTATEs back to HTTP statuses. +// ============================================================ + +import { NextResponse } from "next/server"; +import type { PostgrestError } from "@supabase/supabase-js"; + +import { requireRole, toErrorResponse } from "@/lib/auth/account"; +import { isAccountRole } from "@/lib/auth/roles"; + +// Map known SQLSTATEs from the RPCs (see migration 018) onto HTTP +// statuses. The `error.code` field is the SQLSTATE; the `message` +// is the human-readable RAISE message we put in the migration. +function rpcErrorToResponse(err: PostgrestError): NextResponse { + if (err.code === "42501") { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + if (err.code === "22023") { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error("[members route] unexpected RPC error:", err); + return NextResponse.json( + { error: "Failed to update member" }, + { status: 500 }, + ); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ userId: string }> }, +) { + try { + const ctx = await requireRole("admin"); + const { userId } = await params; + + const body = (await request.json().catch(() => null)) as + | { role?: unknown } + | null; + const role = body?.role; + + if (!isAccountRole(role)) { + return NextResponse.json( + { error: "'role' must be one of owner, admin, agent, viewer" }, + { status: 400 }, + ); + } + + // The RPC blocks promotion to / demotion from owner, but + // surface the friendlier 400 before crossing the wire too. + if (role === "owner") { + return NextResponse.json( + { + error: + "Use POST /api/account/transfer-ownership to promote a member to owner", + }, + { status: 400 }, + ); + } + + const { error } = await ctx.supabase.rpc("set_member_role", { + p_user_id: userId, + p_new_role: role, + }); + + if (error) return rpcErrorToResponse(error); + + return NextResponse.json({ ok: true }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ userId: string }> }, +) { + try { + const ctx = await requireRole("admin"); + const { userId } = await params; + + const { data, error } = await ctx.supabase.rpc("remove_account_member", { + p_user_id: userId, + }); + + if (error) return rpcErrorToResponse(error); + + return NextResponse.json({ ok: true, newPersonalAccountId: data }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/account/members/route.ts b/src/app/api/account/members/route.ts new file mode 100644 index 0000000..f7eb1eb --- /dev/null +++ b/src/app/api/account/members/route.ts @@ -0,0 +1,73 @@ +// ============================================================ +// GET /api/account/members +// +// Lists every member of the caller's account. Any member can call +// it (the Members tab is shown to admins+, but agents/viewers see +// a read-only roster too). +// +// Field visibility +// Sensitive fields (email) are returned only when the caller is +// admin+. Agents and viewers see name + avatar + role + joined +// date only. This mirrors the design decision from the planning +// phase: "agent/viewer sees names only". +// ============================================================ + +import { NextResponse } from "next/server"; + +import { getCurrentAccount, toErrorResponse } from "@/lib/auth/account"; +import { canManageMembers, isAccountRole } from "@/lib/auth/roles"; +import type { AccountMember } from "@/types"; + +interface ProfileRow { + user_id: string; + full_name: string | null; + email: string | null; + avatar_url: string | null; + account_role: string; + created_at: string; +} + +export async function GET() { + try { + const ctx = await getCurrentAccount(); + + // RLS on profiles allows reading any row whose account matches + // the caller's, so this query is naturally account-scoped. + const { data, error } = await ctx.supabase + .from("profiles") + .select("user_id, full_name, email, avatar_url, account_role, created_at") + .eq("account_id", ctx.accountId) + .order("created_at", { ascending: true }); + + if (error) { + console.error("[GET /api/account/members] fetch error:", error); + return NextResponse.json( + { error: "Failed to load members" }, + { status: 500 }, + ); + } + + const canSeeEmails = canManageMembers(ctx.role); + + const members: AccountMember[] = (data as ProfileRow[]).flatMap((row) => { + // Defensive: the DB enum should never let an unknown role + // through, but if a migration ever broadens the enum without + // updating TS, skip the row rather than crash the page. + if (!isAccountRole(row.account_role)) return []; + return [ + { + user_id: row.user_id, + full_name: row.full_name ?? "", + email: canSeeEmails ? row.email : null, + avatar_url: row.avatar_url, + role: row.account_role, + joined_at: row.created_at, + }, + ]; + }); + + return NextResponse.json({ members }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts new file mode 100644 index 0000000..8759ba7 --- /dev/null +++ b/src/app/api/account/route.ts @@ -0,0 +1,88 @@ +// ============================================================ +// /api/account +// +// GET — current caller's account + role. Any member. +// PATCH — rename the account. Admin+. +// +// Why both verbs share a route file +// They speak about the same singular resource (the caller's +// account) and reuse the same `requireRole` plumbing. Splitting +// them across files would duplicate the `account_id` lookup +// without buying anything. +// ============================================================ + +import { NextResponse } from "next/server"; + +import { + requireRole, + getCurrentAccount, + toErrorResponse, +} from "@/lib/auth/account"; + +export async function GET() { + try { + const ctx = await getCurrentAccount(); + return NextResponse.json({ + account: ctx.account, + role: ctx.role, + }); + } catch (err) { + return toErrorResponse(err); + } +} + +const MAX_NAME_LEN = 80; + +export async function PATCH(request: Request) { + try { + const ctx = await requireRole("admin"); + + const body = (await request.json().catch(() => null)) as + | { name?: unknown } + | null; + const rawName = body?.name; + + if (typeof rawName !== "string") { + return NextResponse.json( + { error: "'name' must be a string" }, + { status: 400 }, + ); + } + + const name = rawName.trim(); + if (name.length === 0) { + return NextResponse.json( + { error: "Account name cannot be empty" }, + { status: 400 }, + ); + } + if (name.length > MAX_NAME_LEN) { + return NextResponse.json( + { error: `Account name must be ${MAX_NAME_LEN} characters or fewer` }, + { status: 400 }, + ); + } + + // RLS allows this UPDATE because accounts_update requires + // `is_account_member(id, 'admin')`, and requireRole already + // guaranteed the caller is admin+. + const { data, error } = await ctx.supabase + .from("accounts") + .update({ name }) + .eq("id", ctx.accountId) + .select("id, name") + .single(); + + if (error) { + console.error("[PATCH /api/account] update error:", error); + return NextResponse.json( + { error: "Failed to update account" }, + { status: 500 }, + ); + } + + return NextResponse.json({ account: data }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/account/transfer-ownership/route.ts b/src/app/api/account/transfer-ownership/route.ts new file mode 100644 index 0000000..89831c2 --- /dev/null +++ b/src/app/api/account/transfer-ownership/route.ts @@ -0,0 +1,79 @@ +// ============================================================ +// POST /api/account/transfer-ownership +// +// Owner only. Atomically: +// - demotes the current owner to 'admin' +// - promotes the target member to 'owner' +// - updates accounts.owner_user_id +// +// The atomic part lives in the `transfer_account_ownership` +// SECURITY DEFINER RPC (migration 018). This route just validates +// shape and forwards. +// +// Why a separate endpoint instead of PATCH /members/[userId]? +// The semantics differ: transfer demotes the current owner as +// a side-effect and changes the owner_user_id pointer on +// `accounts`. Making it explicit prevents the "I clicked the +// role dropdown by mistake" failure mode where an admin would +// silently hand their account away. +// ============================================================ + +import { NextResponse } from "next/server"; +import type { PostgrestError } from "@supabase/supabase-js"; + +import { requireRole, toErrorResponse } from "@/lib/auth/account"; + +function rpcErrorToResponse(err: PostgrestError): NextResponse { + if (err.code === "42501") { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + if (err.code === "22023") { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error("[transfer-ownership] unexpected RPC error:", err); + return NextResponse.json( + { error: "Failed to transfer ownership" }, + { status: 500 }, + ); +} + +// Crude shape check — full UUID validation happens DB-side when +// the FK / lookup runs. This guards against obviously-wrong input +// (numbers, objects) before we round-trip. +function looksLikeUuid(v: unknown): v is string { + return ( + typeof v === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) + ); +} + +export async function POST(request: Request) { + try { + // `requireRole('owner')` is belt-and-braces — the RPC checks + // this too, but failing fast here saves a Supabase round trip + // on the obvious "admin trying to transfer" case. + const ctx = await requireRole("owner"); + + const body = (await request.json().catch(() => null)) as + | { newOwnerUserId?: unknown } + | null; + const newOwnerUserId = body?.newOwnerUserId; + + if (!looksLikeUuid(newOwnerUserId)) { + return NextResponse.json( + { error: "'newOwnerUserId' must be a valid UUID" }, + { status: 400 }, + ); + } + + const { error } = await ctx.supabase.rpc("transfer_account_ownership", { + p_new_owner_user_id: newOwnerUserId, + }); + + if (error) return rpcErrorToResponse(error); + + return NextResponse.json({ ok: true }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/supabase/migrations/018_account_member_rpcs.sql b/supabase/migrations/018_account_member_rpcs.sql new file mode 100644 index 0000000..9840302 --- /dev/null +++ b/supabase/migrations/018_account_member_rpcs.sql @@ -0,0 +1,283 @@ +-- ============================================================ +-- 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;