mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #170 from ArnasDon/feat/account-sharing-members-api
feat(api): account + members management endpoints
This commit is contained in:
commit
dabddcfa05
26
CHANGELOG.md
26
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
|
||||
|
||||
|
||||
103
src/app/api/account/members/[userId]/route.ts
Normal file
103
src/app/api/account/members/[userId]/route.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
73
src/app/api/account/members/route.ts
Normal file
73
src/app/api/account/members/route.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
88
src/app/api/account/route.ts
Normal file
88
src/app/api/account/route.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
79
src/app/api/account/transfer-ownership/route.ts
Normal file
79
src/app/api/account/transfer-ownership/route.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
283
supabase/migrations/018_account_member_rpcs.sql
Normal file
283
supabase/migrations/018_account_member_rpcs.sql
Normal file
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user