feat(api): invitation create + peek + redeem endpoints

PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.

API surface
- GET    /api/account/invitations              — list pending  (admin+)
- POST   /api/account/invitations              — create        (admin+)
- DELETE /api/account/invitations/[id]         — revoke        (admin+)
- GET    /api/invitations/[token]/peek         — public, rate-limited per-IP
- POST   /api/invitations/[token]/redeem       — authenticated, rate-limited per-IP

Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
  the response. We store only the SHA-256 hash. There is no
  endpoint that resurfaces it; if the admin dismisses the dialog
  without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
  it in TS via `hashInviteToken`, and look up by `token_hash`.

Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
  envelope: `{ ok, account_name, role, expires_at }` or
  `{ ok: false, reason: not_found|used|expired }`. The route
  forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
  `FOR UPDATE`, validates expiry/usage, refuses if the caller is
  already in a shared account OR their personal account has any
  domain rows (checked across all 11 user-creatable parent
  tables), then atomically moves profile.account_id +
  account_role, stamps the invitation accepted, and deletes the
  orphan personal account. Single transaction.

Error contract
- 22023 (invalid_parameter_value)  → 400 — not_found / used / expired
- 42501 (insufficient_privilege)   → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
                                           or they're already a
                                           member of the inviter's
                                           account / another shared
                                           account

Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
  and invitationRedeem (10/min/IP). Belt-and-braces against
  token enumeration even though 256-bit entropy makes it
  theoretical. Uses the existing fixed-window in-memory limiter.

Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
  two concurrent redemptions can't both win. The peek result is
  cosmetic ("show me what I'm joining"); the actual write
  happens entirely inside the redeem RPC's transaction, so a
  TOCTOU between peek and redeem just means the user sees the
  same `{ ok: false, reason }` on the next render.

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 ƒ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-31 17:32:46 +03:00
parent dabddcfa05
commit b013861160
No known key found for this signature in database
7 changed files with 653 additions and 0 deletions

View File

@ -56,6 +56,24 @@ as the sole owner of their own account and sees identical data.
freshly-created personal account (mirror of the signup flow).
- `POST /api/account/transfer-ownership` — owner only. Atomic
swap with the named member.
- **Invitation API + redeem flow** — the no-email, link-only
invite path. Backend is complete; the Members tab UI that
drives it lands in a follow-up.
- `GET /api/account/invitations` — list outstanding (admin+).
- `POST /api/account/invitations` — create an invite, returns
the plaintext token + share URL **exactly once** (we store
only the SHA-256 hash on the row). Body
`{ role, expiresInDays?, label? }`. Admin+.
- `DELETE /api/account/invitations/[id]` — revoke (admin+).
- `GET /api/invitations/[token]/peek` — public, per-IP
rate-limited. Returns `{ ok, account_name, role, expires_at }`
or `{ ok: false, reason }` so the join page can render
"You're being invited to <Account> as <Role>".
- `POST /api/invitations/[token]/redeem` — authenticated.
Atomically moves the caller's profile to the inviter's
account and cleans up the orphan personal account. Refuses
with 409 if the caller's current account already contains
domain data (no silent data loss).
### Migration required
@ -76,6 +94,12 @@ Apply against your Supabase project before deploying this version:
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
input so the API layer can map cleanly to 403 / 400.
Idempotent.
- `supabase/migrations/019_invitation_rpcs.sql` — adds two
`SECURITY DEFINER` RPCs: `peek_invitation` (anonymous read by
token hash, returns a fixed-shape JSON envelope) and
`redeem_invitation` (authenticated atomic move + orphan
cleanup, with a domain-data safety check). Both bypass the
RLS that would otherwise block their reads/writes. Idempotent.
## [0.2.2] — 2026-05-29

View File

@ -0,0 +1,61 @@
// ============================================================
// DELETE /api/account/invitations/[id]
//
// Admin+. Revokes a pending invitation by id. RLS on
// `account_invitations` already restricts the DELETE to admins
// of the inviting account; we lean on it and skip the explicit
// ownership check.
//
// We intentionally delete the row outright rather than soft-
// deleting (a "revoked_at" flag). Once revoked, an invite is
// dead forever — there's no UX where a former invite should be
// listed; the plaintext token is gone too. Hard delete keeps
// the table small.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const ctx = await requireRole("admin");
const { id } = await params;
// No `eq('account_id', ctx.accountId)` — the RLS policy
// (`is_account_member(account_id, 'admin')`) already scopes
// the DELETE to invites in the caller's account. Adding the
// filter would be redundant; omitting it surfaces a
// cross-account attempt as a silent 0-row delete (which is
// exactly what we want for a revocation endpoint).
const { error, count } = await ctx.supabase
.from("account_invitations")
.delete({ count: "exact" })
.eq("id", id);
if (error) {
console.error("[DELETE /api/account/invitations/[id]] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
if (count === 0) {
// Either the id doesn't exist or RLS hid it (different
// account). 404 either way — surfacing "exists but not
// yours" would leak existence.
return NextResponse.json(
{ error: "Invitation not found" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,144 @@
// ============================================================
// /api/account/invitations
//
// GET — list outstanding (un-redeemed, non-expired) invites.
// POST — create a new invite link.
//
// Both admin+. The list endpoint is what the Members tab uses to
// populate the "Pending invitations" section; create is what the
// "Invite member" dialog calls.
//
// IMPORTANT: the plaintext token is returned exactly ONCE — in
// the POST response. We store only the SHA-256 hash on the row,
// so neither GET nor a future PATCH can ever resurface the
// link. The admin sees it in the creation modal, copies it, and
// shares it via WhatsApp/Slack/whatever they like. If they
// dismiss the modal without copying, the only recourse is to
// revoke and re-issue.
// ============================================================
import { NextResponse } from "next/server";
import { requireRole, toErrorResponse } from "@/lib/auth/account";
import {
clampExpiryDays,
generateInviteToken,
inviteExpiresAt,
inviteUrl,
} from "@/lib/auth/invitations";
import { isAccountRole } from "@/lib/auth/roles";
// Resolve the base URL we publish invite links under. Mirrors the
// .env.local.example default so dev / preview / forks all behave
// without explicit config.
function getBaseUrl(): string {
return process.env.NEXT_PUBLIC_SITE_URL || "https://wacrm.tech";
}
const MAX_LABEL_LEN = 80;
export async function GET() {
try {
const ctx = await requireRole("admin");
const { data, error } = await ctx.supabase
.from("account_invitations")
.select(
"id, role, label, created_by_user_id, created_at, expires_at, accepted_at, accepted_by_user_id",
)
.eq("account_id", ctx.accountId)
.is("accepted_at", null)
.gt("expires_at", new Date().toISOString())
.order("created_at", { ascending: false });
if (error) {
console.error("[GET /api/account/invitations] fetch error:", error);
return NextResponse.json(
{ error: "Failed to load invitations" },
{ status: 500 },
);
}
return NextResponse.json({ invitations: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole("admin");
const body = (await request.json().catch(() => null)) as
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
| null;
const role = body?.role;
if (!isAccountRole(role) || role === "owner") {
// The DB CHECK already rejects 'owner', but failing fast
// here gives a clearer 400 than the eventual constraint
// violation surfaced as a 500.
return NextResponse.json(
{ error: "'role' must be one of admin, agent, viewer" },
{ status: 400 },
);
}
const expiresInDaysRaw = body?.expiresInDays;
// `clampExpiryDays` tolerates undefined / NaN / negatives by
// collapsing to the safe default, so we just pass the raw
// value through after a type narrow.
const expiresInDays =
typeof expiresInDaysRaw === "number" ? expiresInDaysRaw : undefined;
const expiryDays = clampExpiryDays(expiresInDays);
const expiresAt = inviteExpiresAt(expiryDays);
let label: string | null = null;
if (typeof body?.label === "string") {
const trimmed = body.label.trim();
if (trimmed.length > MAX_LABEL_LEN) {
return NextResponse.json(
{ error: `Label must be ${MAX_LABEL_LEN} characters or fewer` },
{ status: 400 },
);
}
label = trimmed === "" ? null : trimmed;
}
const { token, hash } = generateInviteToken();
const { data, error } = await ctx.supabase
.from("account_invitations")
.insert({
account_id: ctx.accountId,
token_hash: hash,
role,
created_by_user_id: ctx.userId,
label,
expires_at: expiresAt.toISOString(),
})
.select("id, role, label, expires_at, created_at")
.single();
if (error || !data) {
console.error("[POST /api/account/invitations] insert error:", error);
return NextResponse.json(
{ error: "Failed to create invitation" },
{ status: 500 },
);
}
return NextResponse.json(
{
invitation: data,
// Plaintext payload — visible to the admin exactly once.
token,
url: inviteUrl(token, getBaseUrl()),
expiresInDays: expiryDays,
},
{ status: 201 },
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,87 @@
// ============================================================
// GET /api/invitations/[token]/peek
//
// Public — no auth required. Lets the /join/<token> page render
// "You're being invited to <Account> as <Role>" before the
// visitor signs up or signs in.
//
// Security model
// - Token is in the URL path, not the query, so it doesn't
// show up in standard access-log "referer" fields the way a
// `?token=` would.
// - The plaintext token never crosses the DB boundary — we
// hash it in TS first and look up by `token_hash`.
// - The peek RPC is SECURITY DEFINER so it bypasses the RLS
// that would otherwise block an anonymous SELECT on
// `account_invitations`. It returns a fixed-shape JSON
// payload that never leaks columns beyond what the join
// page renders.
// - Per-IP rate limit pinches brute-force enumeration of
// tokens. With 256 bits of entropy the enumeration risk is
// theoretical, but rate limiting is cheap insurance.
// ============================================================
import { NextResponse } from "next/server";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
/**
* Best-effort client IP. The `x-forwarded-for` header is what
* every reverse proxy (Vercel, Hostinger, Cloudflare) sets when
* forwarding a request; we take the leftmost entry, which is
* the original client.
*
* Falls back to a constant when no proxy is in front (e.g.
* `localhost` during development) so rate-limit keys still
* exist — the limit then effectively applies "globally," which
* is fine for dev.
*/
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
// Rate-limit by IP first. Returns 429 to a serial bruteforcer
// before we ever touch the DB.
const ip = getClientIp(request);
const limit = checkRateLimit(`peek:${ip}`, RATE_LIMITS.invitationPeek);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ ok: false, reason: "not_found" },
{ status: 404 },
);
}
const supabase = await createClient();
const { data, error } = await supabase.rpc("peek_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) {
console.error("[peek] rpc error:", error);
return NextResponse.json(
{ ok: false, reason: "server_error" },
{ status: 500 },
);
}
// The RPC always returns a json object — either ok:true with
// metadata or ok:false with a reason. Forward verbatim.
return NextResponse.json(data);
}

View File

@ -0,0 +1,91 @@
// ============================================================
// POST /api/invitations/[token]/redeem
//
// Authenticated. Caller atomically moves from their personal
// account (created at signup) to the inviter's account with the
// invite's role. Heavy lifting lives in the SECURITY DEFINER
// `redeem_invitation` RPC from migration 019.
//
// Refusal contract (from the RPC)
// - SQLSTATE 42501 → 401 (caller not authenticated)
// - SQLSTATE 22023 → 400 (invitation not_found / used / expired)
// - SQLSTATE 23505 → 409 (caller's account already has data /
// they're already in this or another shared account)
//
// Rate limit (per IP) is the same shape as peek but tighter —
// a successful redeem changes data, and the RPC's data-loss
// guard makes brute-force retries pointless past a few attempts.
// ============================================================
import { NextResponse } from "next/server";
import type { PostgrestError } from "@supabase/supabase-js";
import { hashInviteToken } from "@/lib/auth/invitations";
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from "@/lib/rate-limit";
import { createClient } from "@/lib/supabase/server";
function getClientIp(request: Request): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xri = request.headers.get("x-real-ip");
if (xri) return xri.trim();
return "unknown";
}
function rpcErrorToResponse(err: PostgrestError): NextResponse {
if (err.code === "42501") {
return NextResponse.json({ error: err.message }, { status: 401 });
}
if (err.code === "22023") {
return NextResponse.json({ error: err.message }, { status: 400 });
}
if (err.code === "23505") {
return NextResponse.json({ error: err.message }, { status: 409 });
}
console.error("[redeem] unexpected RPC error:", err);
return NextResponse.json(
{ error: "Failed to redeem invitation" },
{ status: 500 },
);
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ token: string }> },
) {
const ip = getClientIp(request);
const limit = checkRateLimit(`redeem:${ip}`, RATE_LIMITS.invitationRedeem);
if (!limit.success) return rateLimitResponse(limit);
const { token } = await params;
if (!token || typeof token !== "string") {
return NextResponse.json(
{ error: "Missing invitation token" },
{ status: 400 },
);
}
const supabase = await createClient();
// The RPC checks `auth.uid()` itself, but failing fast here
// gives a cleaner 401 without a Supabase round trip on the
// common "user clicked the link before logging in" path.
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { data: accountId, error } = await supabase.rpc("redeem_invitation", {
p_token_hash: hashInviteToken(token),
});
if (error) return rpcErrorToResponse(error);
return NextResponse.json({ ok: true, accountId });
}

View File

@ -125,6 +125,15 @@ export const RATE_LIMITS = {
* fidget with reactions and a single "swap" is actually two calls
* (remove + add) under the hood. */
react: { limit: 120, windowMs: 60_000 },
/** Invitation peek (public, per-IP). 30/min lets a forwarded link
* retry a handful of times under flaky connectivity without
* enabling brute-force token enumeration. With 256-bit tokens the
* enumeration risk is theoretical; this is belt-and-braces. */
invitationPeek: { limit: 30, windowMs: 60_000 },
/** Invitation redeem (authed, per-IP+user). Tighter than peek —
* successful redemption mutates two profiles and an invite row, so
* the abuse surface is "spam join attempts." */
invitationRedeem: { limit: 10, windowMs: 60_000 },
} as const;
/** Test-only helper. Clears the in-memory state so unit tests don't

View File

@ -0,0 +1,237 @@
-- ============================================================
-- 019_invitation_rpcs.sql — peek + redeem invitation RPCs
--
-- The third and last server-side migration in the multi-user
-- accounts series. Both functions are SECURITY DEFINER for the
-- same reason as the member RPCs in 018: the writes they need to
-- do (or, for peek, the reads) cross RLS boundaries that the
-- regular client policies (correctly) deny.
--
-- peek_invitation — anonymous read. The /join/<token> page
-- calls this to render "You're being invited to <Account> as
-- <Role>" before the visitor signs in. Returns a uniform
-- `{ ok, reason?, account_name?, role?, expires_at? }` JSON
-- so the API route doesn't have to interpret error rows.
--
-- redeem_invitation — authenticated. Atomically moves the caller
-- from their just-created personal account to the inviter's
-- account, cleans up the orphan personal account, and stamps
-- the invitation accepted. Refuses if the caller's current
-- account holds any domain data (to avoid silent data loss).
--
-- Idempotent — safe to run multiple times.
-- ============================================================
-- ============================================================
-- peek_invitation(p_token_hash text)
--
-- Anonymous read by token hash. The plaintext token never
-- reaches the DB; the route handler hashes it first.
--
-- Returns a JSON object with one of two shapes:
-- { "ok": true, "account_name": "...", "role": "...",
-- "expires_at": "2026-..." }
-- { "ok": false, "reason": "not_found" | "expired" | "used" }
--
-- We could collapse all three failure cases to "not_found" to
-- harden against enumeration, but the join page needs the
-- distinction for UX ("This invite has expired — ask <name>
-- for a new one"). Tokens carry 256 bits of entropy, so the
-- enumeration risk is theoretical; rate-limiting the route on
-- the IP layer adds belt-and-braces.
-- ============================================================
CREATE OR REPLACE FUNCTION public.peek_invitation(
p_token_hash TEXT
) RETURNS JSON
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_inv account_invitations%ROWTYPE;
v_account_name TEXT;
BEGIN
SELECT * INTO v_inv
FROM account_invitations
WHERE token_hash = p_token_hash;
IF NOT FOUND THEN
RETURN json_build_object('ok', false, 'reason', 'not_found');
END IF;
IF v_inv.accepted_at IS NOT NULL THEN
RETURN json_build_object('ok', false, 'reason', 'used');
END IF;
IF v_inv.expires_at <= NOW() THEN
RETURN json_build_object('ok', false, 'reason', 'expired');
END IF;
SELECT name INTO v_account_name
FROM accounts
WHERE id = v_inv.account_id;
RETURN json_build_object(
'ok', true,
'account_name', v_account_name,
'role', v_inv.role,
'expires_at', v_inv.expires_at
);
END;
$$;
ALTER FUNCTION public.peek_invitation(TEXT) OWNER TO postgres;
REVOKE ALL ON FUNCTION public.peek_invitation(TEXT) FROM PUBLIC;
-- `anon` so the /join/<token> page can call this before the user
-- signs in; `authenticated` so the same page works when already
-- signed in (e.g. existing user clicks a forwarded link).
GRANT EXECUTE ON FUNCTION public.peek_invitation(TEXT) TO anon, authenticated;
-- ============================================================
-- redeem_invitation(p_token_hash text)
--
-- Authenticated. The caller's auth.uid() is used both to scope
-- the move ("which profile am I editing?") and as the safety
-- check ("do you have any data we'd lose?").
--
-- Refusal codes (SQLSTATE):
-- 22023 — invite invalid (not_found / used / expired)
-- 42501 — caller not authenticated
-- 23505 — caller's account has data (would be lost by joining)
-- NOTE: we reuse Postgres's "unique_violation" code here
-- rather than invent a custom SQLSTATE because there's
-- no proper standard SQLSTATE for "conflict"; the route
-- handler maps it to HTTP 409.
--
-- Order of operations
-- 1. Lock the invite row (FOR UPDATE) so two concurrent redeems
-- of the same token can't both succeed.
-- 2. Read caller's current account_id.
-- 3. Verify caller is the sole owner of their current account
-- AND that the account has zero domain rows. (If the caller
-- already joined someone else's account once, their
-- profile.account_id points there, not to a personal account
-- they own — that case fails the "is owner" check and
-- surfaces as 23505.)
-- 4. Move profile.account_id + account_role to invite's.
-- 5. Mark invitation accepted (token_hash stays, so the same
-- token can't be re-used).
-- 6. Delete the old personal account. The ON DELETE CASCADE on
-- `accounts(id) ← profiles.account_id` would normally try to
-- delete the caller's profile too, but step 4 already moved
-- them to the new account, so the cascade is a no-op.
-- ============================================================
CREATE OR REPLACE FUNCTION public.redeem_invitation(
p_token_hash TEXT
) RETURNS UUID -- the joined account_id
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_id UUID := auth.uid();
v_inv account_invitations%ROWTYPE;
v_old_account_id UUID;
v_old_account_owner UUID;
v_has_data BOOLEAN;
BEGIN
IF v_caller_id IS NULL THEN
RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501';
END IF;
SELECT * INTO v_inv
FROM account_invitations
WHERE token_hash = p_token_hash
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Invitation not found' USING ERRCODE = '22023';
END IF;
IF v_inv.accepted_at IS NOT NULL THEN
RAISE EXCEPTION 'Invitation has already been redeemed'
USING ERRCODE = '22023';
END IF;
IF v_inv.expires_at <= NOW() THEN
RAISE EXCEPTION 'Invitation has expired' USING ERRCODE = '22023';
END IF;
-- Caller's current account + its owner.
SELECT p.account_id, a.owner_user_id
INTO v_old_account_id, v_old_account_owner
FROM profiles p
JOIN accounts a ON a.id = p.account_id
WHERE p.user_id = v_caller_id;
IF v_old_account_id IS NULL THEN
-- Defensive — every authenticated user has a profile post-017.
RAISE EXCEPTION 'Caller has no profile' USING ERRCODE = '42501';
END IF;
-- Edge case: the inviter sent themselves a link, or the
-- caller is somehow already in the inviter's account.
IF v_old_account_id = v_inv.account_id THEN
RAISE EXCEPTION 'You are already a member of this account'
USING ERRCODE = '23505';
END IF;
-- Safety: the caller must be the SOLE OWNER of their current
-- account (i.e. their fresh personal account from signup or a
-- prior removal). Any other state means they're either:
-- - a member of another shared account (joining a second
-- would silently orphan their access to the first), or
-- - the owner of an account with teammates (they'd abandon
-- their team to join the inviter's).
-- Either way, the safe answer is "make a different login".
IF v_old_account_owner <> v_caller_id THEN
RAISE EXCEPTION 'You are already in a shared account; sign up with a different email to join this one'
USING ERRCODE = '23505';
END IF;
-- Belt: even if they own their account, refuse if it has any
-- domain data — joining would orphan their contacts, deals,
-- broadcasts, automations, flows, templates, etc.
SELECT EXISTS (
SELECT 1 FROM contacts WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM conversations WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM broadcasts WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM automations WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM flows WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM pipelines WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM message_templates WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM tags WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM custom_fields WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM contact_notes WHERE account_id = v_old_account_id
UNION ALL SELECT 1 FROM whatsapp_config WHERE account_id = v_old_account_id
LIMIT 1
) INTO v_has_data;
IF v_has_data THEN
RAISE EXCEPTION 'Your account already contains data; sign up with a different email to join this one'
USING ERRCODE = '23505';
END IF;
-- Move the profile first so the cascade-on-delete of the old
-- account doesn't try to nuke this user's profile too.
UPDATE profiles
SET account_id = v_inv.account_id,
account_role = v_inv.role
WHERE user_id = v_caller_id;
UPDATE account_invitations
SET accepted_at = NOW(),
accepted_by_user_id = v_caller_id
WHERE id = v_inv.id;
-- Clean up the orphan personal account. Empty by the checks
-- above, so this is purely housekeeping — no cascades fire
-- because no other rows reference it.
DELETE FROM accounts WHERE id = v_old_account_id;
RETURN v_inv.account_id;
END;
$$;
ALTER FUNCTION public.redeem_invitation(TEXT) OWNER TO postgres;
REVOKE ALL ON FUNCTION public.redeem_invitation(TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.redeem_invitation(TEXT) TO authenticated;